release.rs

  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}