release.rs

  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    fn env_name(name: &str) -> String {
276        format!("RESULT_{}", name.to_uppercase())
277    }
278
279    let all_job_names: Vec<&str> = test_jobs
280        .iter()
281        .map(|j| j.name.as_ref())
282        .chain(bundle_jobs.jobs().into_iter().map(|j| j.name.as_ref()))
283        .collect();
284
285    let env_entries = [
286        (
287            "DRAFT_RESULT".into(),
288            format!("${{{{ needs.{}.result }}}}", create_draft_release_job.name),
289        ),
290        (
291            "UPLOAD_RESULT".into(),
292            format!("${{{{ needs.{}.result }}}}", upload_assets_job.name),
293        ),
294        (
295            "VALIDATE_RESULT".into(),
296            format!("${{{{ needs.{}.result }}}}", validate_assets_job.name),
297        ),
298        (
299            "AUTO_RELEASE_RESULT".into(),
300            format!("${{{{ needs.{}.result }}}}", auto_release_preview.name),
301        ),
302        ("RUN_URL".into(), CURRENT_ACTION_RUN_URL.to_string()),
303    ]
304    .into_iter()
305    .chain(
306        all_job_names
307            .iter()
308            .map(|name| (env_name(name), format!("${{{{ needs.{name}.result }}}}"))),
309    );
310
311    let failure_checks = all_job_names
312        .iter()
313        .map(|name| {
314            format!(
315                "if [ \"${env_name}\" == \"failure\" ];then FAILED_JOBS=\"$FAILED_JOBS {name}\"; fi",
316                    env_name = env_name(name)
317            )
318        })
319        .collect::<Vec<_>>()
320        .join("\n        ");
321
322    let notification_script = formatdoc! {r#"
323        TAG="$GITHUB_REF_NAME"
324
325        if [ "$DRAFT_RESULT" == "failure" ]; then
326            echo "❌ Draft release creation failed for $TAG: $RUN_URL"
327        else
328            RELEASE_URL=$(gh release view "$TAG" --repo=zed-industries/zed --json url -q '.url')
329            if [ "$UPLOAD_RESULT" == "failure" ]; then
330                echo "❌ Release asset upload failed for $TAG: $RELEASE_URL"
331            elif [ "$UPLOAD_RESULT" == "cancelled" ] || [ "$UPLOAD_RESULT" == "skipped" ]; then
332                FAILED_JOBS=""
333                {failure_checks}
334                FAILED_JOBS=$(echo "$FAILED_JOBS" | xargs)
335                if [ "$UPLOAD_RESULT" == "cancelled" ]; then
336                    if [ -n "$FAILED_JOBS" ]; then
337                        echo "❌ Release job for $TAG was cancelled, most likely because tests \`$FAILED_JOBS\` failed: $RUN_URL"
338                    else
339                        echo "❌ Release job for $TAG was cancelled: $RUN_URL"
340                    fi
341                else
342                    if [ -n "$FAILED_JOBS" ]; then
343                        echo "❌ Tests \`$FAILED_JOBS\` for $TAG failed: $RUN_URL"
344                    else
345                        echo "❌ Tests for $TAG failed: $RUN_URL"
346                    fi
347                fi
348            elif [ "$VALIDATE_RESULT" == "failure" ]; then
349                echo "❌ Release asset validation failed for $TAG (missing assets): $RUN_URL"
350            elif [ "$AUTO_RELEASE_RESULT" == "success" ]; then
351                echo "✅ Release $TAG was auto-released successfully: $RELEASE_URL"
352            elif [ "$AUTO_RELEASE_RESULT" == "failure" ]; then
353                echo "❌ Auto release failed for $TAG: $RUN_URL"
354            else
355                echo "👀 Release $TAG sitting freshly baked in the oven and waiting to be published: $RELEASE_URL"
356            fi
357        fi
358        "#,
359    };
360
361    let mut all_deps: Vec<&NamedJob> = vec![
362        create_draft_release_job,
363        upload_assets_job,
364        validate_assets_job,
365        auto_release_preview,
366    ];
367    all_deps.extend(test_jobs.iter().copied());
368    all_deps.extend(bundle_jobs.jobs());
369
370    let mut job = dependant_job(&all_deps)
371        .runs_on(runners::LINUX_SMALL)
372        .cond(Expression::new("always()"));
373
374    for step in notify_slack(MessageType::Evaluated {
375        script: notification_script,
376        env: env_entries.collect(),
377    }) {
378        job = job.add_step(step);
379    }
380    named::job(job)
381}
382
383pub(crate) fn notify_on_failure(deps: &[&NamedJob]) -> NamedJob {
384    let failure_message = format!("❌ ${{{{ github.workflow }}}} failed: {CURRENT_ACTION_RUN_URL}");
385
386    let mut job = dependant_job(deps)
387        .runs_on(runners::LINUX_SMALL)
388        .cond(Expression::new("failure()"));
389
390    for step in notify_slack(MessageType::Static(failure_message)) {
391        job = job.add_step(step);
392    }
393    named::job(job)
394}
395
396pub(crate) enum MessageType {
397    Static(String),
398    Evaluated {
399        script: String,
400        env: Vec<(String, String)>,
401    },
402}
403
404fn notify_slack(message: MessageType) -> Vec<Step<Run>> {
405    match message {
406        MessageType::Static(message) => vec![send_slack_message(message)],
407        MessageType::Evaluated { script, env } => {
408            let (generate_step, generated_message) = generate_slack_message(script, env);
409
410            vec![
411                generate_step,
412                send_slack_message(generated_message.to_string()),
413            ]
414        }
415    }
416}
417
418fn generate_slack_message(
419    expression: String,
420    env: Vec<(String, String)>,
421) -> (Step<Run>, StepOutput) {
422    let script = formatdoc! {r#"
423        MESSAGE=$({expression})
424        echo "message=$MESSAGE" >> "$GITHUB_OUTPUT"
425        "#
426    };
427    let mut generate_step = named::bash(&script)
428        .id("generate-webhook-message")
429        .add_env(("GH_TOKEN", Context::github().token()));
430
431    for (name, value) in env {
432        generate_step = generate_step.add_env((name, value));
433    }
434
435    let output = StepOutput::new(&generate_step, "message");
436
437    (generate_step, output)
438}
439
440fn send_slack_message(message: String) -> Step<Run> {
441    named::bash(
442        r#"curl -X POST -H 'Content-type: application/json' --data "$(jq -n --arg text "$SLACK_MESSAGE" '{"text": $text}')" "$SLACK_WEBHOOK""#
443    )
444    .add_env(("SLACK_WEBHOOK", vars::SLACK_WEBHOOK_WORKFLOW_FAILURES))
445    .add_env(("SLACK_MESSAGE", message))
446}