release.rs

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