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},
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);
20 let linux_clippy = run_tests::clippy(Platform::Linux);
21 let windows_clippy = run_tests::clippy(Platform::Windows);
22 let check_scripts = run_tests::check_scripts();
23
24 let create_draft_release = create_draft_release();
25
26 let bundle = ReleaseBundleJobs {
27 linux_aarch64: bundle_linux(
28 Arch::AARCH64,
29 None,
30 &[&linux_tests, &linux_clippy, &check_scripts],
31 ),
32 linux_x86_64: bundle_linux(
33 Arch::X86_64,
34 None,
35 &[&linux_tests, &linux_clippy, &check_scripts],
36 ),
37 mac_aarch64: bundle_mac(
38 Arch::AARCH64,
39 None,
40 &[&macos_tests, &macos_clippy, &check_scripts],
41 ),
42 mac_x86_64: bundle_mac(
43 Arch::X86_64,
44 None,
45 &[&macos_tests, &macos_clippy, &check_scripts],
46 ),
47 windows_aarch64: bundle_windows(
48 Arch::AARCH64,
49 None,
50 &[&windows_tests, &windows_clippy, &check_scripts],
51 ),
52 windows_x86_64: bundle_windows(
53 Arch::X86_64,
54 None,
55 &[&windows_tests, &windows_clippy, &check_scripts],
56 ),
57 };
58
59 let upload_release_assets = upload_release_assets(&[&create_draft_release], &bundle);
60 let validate_release_assets = validate_release_assets(&[&upload_release_assets]);
61
62 let auto_release_preview = auto_release_preview(&[&validate_release_assets]);
63
64 let test_jobs = [
65 &macos_tests,
66 &linux_tests,
67 &windows_tests,
68 &macos_clippy,
69 &linux_clippy,
70 &windows_clippy,
71 &check_scripts,
72 ];
73 let push_slack_notification = push_release_update_notification(
74 &create_draft_release,
75 &upload_release_assets,
76 &validate_release_assets,
77 &auto_release_preview,
78 &test_jobs,
79 &bundle,
80 );
81
82 named::workflow()
83 .on(Event::default().push(Push::default().tags(vec!["v*".to_string()])))
84 .concurrency(vars::one_workflow_per_non_main_branch())
85 .add_env(("CARGO_TERM_COLOR", "always"))
86 .add_env(("RUST_BACKTRACE", "1"))
87 .add_job(macos_tests.name, macos_tests.job)
88 .add_job(linux_tests.name, linux_tests.job)
89 .add_job(windows_tests.name, windows_tests.job)
90 .add_job(macos_clippy.name, macos_clippy.job)
91 .add_job(linux_clippy.name, linux_clippy.job)
92 .add_job(windows_clippy.name, windows_clippy.job)
93 .add_job(check_scripts.name, check_scripts.job)
94 .add_job(create_draft_release.name, create_draft_release.job)
95 .map(|mut workflow| {
96 for job in bundle.into_jobs() {
97 workflow = workflow.add_job(job.name, job.job);
98 }
99 workflow
100 })
101 .add_job(upload_release_assets.name, upload_release_assets.job)
102 .add_job(validate_release_assets.name, validate_release_assets.job)
103 .add_job(auto_release_preview.name, auto_release_preview.job)
104 .add_job(push_slack_notification.name, push_slack_notification.job)
105}
106
107pub(crate) struct ReleaseBundleJobs {
108 pub linux_aarch64: NamedJob,
109 pub linux_x86_64: NamedJob,
110 pub mac_aarch64: NamedJob,
111 pub mac_x86_64: NamedJob,
112 pub windows_aarch64: NamedJob,
113 pub windows_x86_64: NamedJob,
114}
115
116impl ReleaseBundleJobs {
117 pub fn jobs(&self) -> Vec<&NamedJob> {
118 vec![
119 &self.linux_aarch64,
120 &self.linux_x86_64,
121 &self.mac_aarch64,
122 &self.mac_x86_64,
123 &self.windows_aarch64,
124 &self.windows_x86_64,
125 ]
126 }
127
128 pub fn into_jobs(self) -> Vec<NamedJob> {
129 vec![
130 self.linux_aarch64,
131 self.linux_x86_64,
132 self.mac_aarch64,
133 self.mac_x86_64,
134 self.windows_aarch64,
135 self.windows_x86_64,
136 ]
137 }
138}
139
140pub(crate) fn create_sentry_release() -> Step<Use> {
141 named::uses(
142 "getsentry",
143 "action-release",
144 "526942b68292201ac6bbb99b9a0747d4abee354c", // v3
145 )
146 .add_env(("SENTRY_ORG", "zed-dev"))
147 .add_env(("SENTRY_PROJECT", "zed"))
148 .add_env(("SENTRY_AUTH_TOKEN", vars::SENTRY_AUTH_TOKEN))
149 .add_with(("environment", "production"))
150}
151
152fn validate_release_assets(deps: &[&NamedJob]) -> NamedJob {
153 let expected_assets: Vec<String> = assets::all().iter().map(|a| format!("\"{a}\"")).collect();
154 let expected_assets_json = format!("[{}]", expected_assets.join(", "));
155
156 let validation_script = formatdoc! {r#"
157 EXPECTED_ASSETS='{expected_assets_json}'
158 TAG="$GITHUB_REF_NAME"
159
160 ACTUAL_ASSETS=$(gh release view "$TAG" --repo=zed-industries/zed --json assets -q '[.assets[].name]')
161
162 MISSING_ASSETS=$(echo "$EXPECTED_ASSETS" | jq -r --argjson actual "$ACTUAL_ASSETS" '. - $actual | .[]')
163
164 if [ -n "$MISSING_ASSETS" ]; then
165 echo "Error: The following assets are missing from the release:"
166 echo "$MISSING_ASSETS"
167 exit 1
168 fi
169
170 echo "All expected assets are present in the release."
171 "#,
172 };
173
174 named::job(
175 dependant_job(deps).runs_on(runners::LINUX_SMALL).add_step(
176 named::bash(&validation_script).add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
177 ),
178 )
179}
180
181fn auto_release_preview(deps: &[&NamedJob]) -> NamedJob {
182 let (authenticate, token) = steps::authenticate_as_zippy();
183
184 named::job(
185 dependant_job(deps)
186 .runs_on(runners::LINUX_SMALL)
187 .cond(Expression::new(indoc::indoc!(
188 r#"startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre')"#
189 )))
190 .add_step(authenticate)
191 .add_step(
192 steps::script(
193 r#"gh release edit "$GITHUB_REF_NAME" --repo=zed-industries/zed --draft=false"#,
194 )
195 .add_env(("GITHUB_TOKEN", &token)),
196 )
197 )
198}
199
200pub(crate) fn download_workflow_artifacts() -> Step<Use> {
201 named::uses(
202 "actions",
203 "download-artifact",
204 "018cc2cf5baa6db3ef3c5f8a56943fffe632ef53", // v6.0.0
205 )
206 .add_with(("path", "./artifacts/"))
207}
208
209pub(crate) fn prep_release_artifacts() -> Step<Run> {
210 let mut script_lines = vec!["mkdir -p release-artifacts/\n".to_string()];
211 for asset in assets::all() {
212 let mv_command = format!("mv ./artifacts/{asset}/{asset} release-artifacts/{asset}");
213 script_lines.push(mv_command)
214 }
215
216 named::bash(&script_lines.join("\n"))
217}
218
219fn upload_release_assets(deps: &[&NamedJob], bundle: &ReleaseBundleJobs) -> NamedJob {
220 let mut deps = deps.to_vec();
221 deps.extend(bundle.jobs());
222
223 named::job(
224 dependant_job(&deps)
225 .runs_on(runners::LINUX_MEDIUM)
226 .add_step(download_workflow_artifacts())
227 .add_step(steps::script("ls -lR ./artifacts"))
228 .add_step(prep_release_artifacts())
229 .add_step(
230 steps::script("gh release upload \"$GITHUB_REF_NAME\" --repo=zed-industries/zed release-artifacts/*")
231 .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN)),
232 ),
233 )
234}
235
236fn create_draft_release() -> NamedJob {
237 fn generate_release_notes() -> Step<Run> {
238 named::bash(
239 r#"node --redirect-warnings=/dev/null ./script/draft-release-notes "$RELEASE_VERSION" "$RELEASE_CHANNEL" > target/release-notes.md"#,
240 )
241 }
242
243 fn create_release() -> Step<Run> {
244 named::bash("script/create-draft-release target/release-notes.md")
245 .add_env(("GITHUB_TOKEN", vars::GITHUB_TOKEN))
246 }
247
248 named::job(
249 release_job(&[])
250 .runs_on(runners::LINUX_SMALL)
251 // We need to fetch more than one commit so that `script/draft-release-notes`
252 // is able to diff between the current and previous tag.
253 //
254 // 25 was chosen arbitrarily.
255 .add_step(
256 steps::checkout_repo()
257 .with_custom_fetch_depth(25)
258 .with_ref("${{ github.ref }}"),
259 )
260 .add_step(steps::script("script/determine-release-channel"))
261 .add_step(steps::script("mkdir -p target/"))
262 .add_step(generate_release_notes())
263 .add_step(create_release()),
264 )
265}
266
267pub(crate) fn push_release_update_notification(
268 create_draft_release_job: &NamedJob,
269 upload_assets_job: &NamedJob,
270 validate_assets_job: &NamedJob,
271 auto_release_preview: &NamedJob,
272 test_jobs: &[&NamedJob],
273 bundle_jobs: &ReleaseBundleJobs,
274) -> NamedJob {
275 let all_job_names = test_jobs
276 .into_iter()
277 .map(|j| j.name.as_ref())
278 .chain(bundle_jobs.jobs().into_iter().map(|j| j.name.as_ref()));
279
280 let notification_script = formatdoc! {r#"
281 DRAFT_RESULT="${{{{ needs.{draft_job}.result }}}}"
282 UPLOAD_RESULT="${{{{ needs.{upload_job}.result }}}}"
283 VALIDATE_RESULT="${{{{ needs.{validate_job}.result }}}}"
284 AUTO_RELEASE_RESULT="${{{{ needs.{auto_release_job}.result }}}}"
285 TAG="$GITHUB_REF_NAME"
286 RUN_URL="{run_url}"
287
288 if [ "$DRAFT_RESULT" == "failure" ]; then
289 echo "❌ Draft release creation failed for $TAG: $RUN_URL"
290 else
291 RELEASE_URL=$(gh release view "$TAG" --repo=zed-industries/zed --json url -q '.url')
292 if [ "$UPLOAD_RESULT" == "failure" ]; then
293 echo "❌ Release asset upload failed for $TAG: $RELEASE_URL"
294 elif [ "$UPLOAD_RESULT" == "cancelled" ] || [ "$UPLOAD_RESULT" == "skipped" ]; then
295 FAILED_JOBS=""
296 {failure_checks}
297 FAILED_JOBS=$(echo "$FAILED_JOBS" | xargs)
298 if [ "$UPLOAD_RESULT" == "cancelled" ]; then
299 if [ -n "$FAILED_JOBS" ]; then
300 echo "❌ Release job for $TAG was cancelled, most likely because tests \`$FAILED_JOBS\` failed: $RUN_URL"
301 else
302 echo "❌ Release job for $TAG was cancelled: $RUN_URL"
303 fi
304 else
305 if [ -n "$FAILED_JOBS" ]; then
306 echo "❌ Tests \`$FAILED_JOBS\` for $TAG failed: $RUN_URL"
307 else
308 echo "❌ Tests for $TAG failed: $RUN_URL"
309 fi
310 fi
311 elif [ "$VALIDATE_RESULT" == "failure" ]; then
312 echo "❌ Release asset validation failed for $TAG (missing assets): $RUN_URL"
313 elif [ "$AUTO_RELEASE_RESULT" == "success" ]; then
314 echo "✅ Release $TAG was auto-released successfully: $RELEASE_URL"
315 elif [ "$AUTO_RELEASE_RESULT" == "failure" ]; then
316 echo "❌ Auto release failed for $TAG: $RUN_URL"
317 else
318 echo "👀 Release $TAG sitting freshly baked in the oven and waiting to be published: $RELEASE_URL"
319 fi
320 fi
321 "#,
322 draft_job = create_draft_release_job.name,
323 upload_job = upload_assets_job.name,
324 validate_job = validate_assets_job.name,
325 auto_release_job = auto_release_preview.name,
326 run_url = CURRENT_ACTION_RUN_URL,
327 failure_checks = all_job_names
328 .into_iter()
329 .map(|name: &str| format!(
330 "if [ \"${{{{ needs.{name}.result }}}}\" == \"failure\" ];\
331 then FAILED_JOBS=\"$FAILED_JOBS {name}\"; fi"
332 ))
333 .collect::<Vec<_>>()
334 .join("\n "),
335 };
336
337 let mut all_deps: Vec<&NamedJob> = vec![
338 create_draft_release_job,
339 upload_assets_job,
340 validate_assets_job,
341 auto_release_preview,
342 ];
343 all_deps.extend(test_jobs.iter().copied());
344 all_deps.extend(bundle_jobs.jobs());
345
346 let mut job = dependant_job(&all_deps)
347 .runs_on(runners::LINUX_SMALL)
348 .cond(Expression::new("always()"));
349
350 for step in notify_slack(MessageType::Evaluated(notification_script)) {
351 job = job.add_step(step);
352 }
353 named::job(job)
354}
355
356pub(crate) fn notify_on_failure(deps: &[&NamedJob]) -> NamedJob {
357 let failure_message = format!("❌ ${{{{ github.workflow }}}} failed: {CURRENT_ACTION_RUN_URL}");
358
359 let mut job = dependant_job(deps)
360 .runs_on(runners::LINUX_SMALL)
361 .cond(Expression::new("failure()"));
362
363 for step in notify_slack(MessageType::Static(failure_message)) {
364 job = job.add_step(step);
365 }
366 named::job(job)
367}
368
369pub(crate) enum MessageType {
370 Static(String),
371 Evaluated(String),
372}
373
374fn notify_slack(message: MessageType) -> Vec<Step<Run>> {
375 match message {
376 MessageType::Static(message) => vec![send_slack_message(message)],
377 MessageType::Evaluated(expression) => {
378 let (generate_step, generated_message) = generate_slack_message(expression);
379
380 vec![
381 generate_step,
382 send_slack_message(generated_message.to_string()),
383 ]
384 }
385 }
386}
387
388fn generate_slack_message(expression: String) -> (Step<Run>, StepOutput) {
389 let script = formatdoc! {r#"
390 MESSAGE=$({expression})
391 echo "message=$MESSAGE" >> "$GITHUB_OUTPUT"
392 "#
393 };
394 let generate_step = named::bash(&script)
395 .id("generate-webhook-message")
396 .add_env(("GH_TOKEN", Context::github().token()));
397
398 let output = StepOutput::new(&generate_step, "message");
399
400 (generate_step, output)
401}
402
403fn send_slack_message(message: String) -> Step<Run> {
404 let script = formatdoc! {r#"
405 curl -X POST -H 'Content-type: application/json'\
406 --data '{{"text":"{message}"}}' "$SLACK_WEBHOOK"
407 "#
408 };
409 named::bash(&script).add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
410}