1use gh_workflow::{ctx::Context, *};
2use indoc::{formatdoc, indoc};
3
4use crate::tasks::workflows::{
5 extension_tests::{self},
6 runners,
7 steps::{
8 self, BASH_SHELL, CommonJobConditions, DEFAULT_REPOSITORY_OWNER_GUARD, FluentBuilder,
9 NamedJob, cache_rust_dependencies_namespace, checkout_repo, dependant_job, named,
10 },
11 vars::{
12 JobOutput, StepOutput, WorkflowInput, WorkflowSecret,
13 one_workflow_per_non_main_branch_and_token,
14 },
15};
16
17const VERSION_CHECK: &str =
18 r#"sed -n 's/^version = \"\(.*\)\"/\1/p' < extension.toml | tr -d '[:space:]'"#;
19
20// This is used by various extensions repos in the zed-extensions org to bump extension versions.
21pub(crate) fn extension_bump() -> Workflow {
22 let bump_type = WorkflowInput::string("bump-type", Some("patch".to_owned()));
23 // TODO: Ideally, this would have a default of `false`, but this is currently not
24 // supported in gh-workflows
25 let force_bump = WorkflowInput::bool("force-bump", None);
26 let working_directory = WorkflowInput::string("working-directory", Some(".".to_owned()));
27
28 let (app_id, app_secret) = extension_workflow_secrets();
29 let (check_version_changed, version_changed, current_version) = check_version_changed();
30
31 let version_changed = version_changed.as_job_output(&check_version_changed);
32 let current_version = current_version.as_job_output(&check_version_changed);
33
34 let dependencies = [&check_version_changed];
35 let bump_version = bump_extension_version(
36 &dependencies,
37 ¤t_version,
38 &bump_type,
39 &version_changed,
40 &force_bump,
41 &app_id,
42 &app_secret,
43 );
44 let (create_label, tag) = create_version_label(
45 &dependencies,
46 &version_changed,
47 ¤t_version,
48 &app_id,
49 &app_secret,
50 );
51 let tag = tag.as_job_output(&create_label);
52 let trigger_release = trigger_release(
53 &[&check_version_changed, &create_label],
54 tag,
55 &app_id,
56 &app_secret,
57 );
58
59 named::workflow()
60 .add_event(
61 Event::default().workflow_call(
62 WorkflowCall::default()
63 .add_input(bump_type.name, bump_type.call_input())
64 .add_input(force_bump.name, force_bump.call_input())
65 .add_input(working_directory.name, working_directory.call_input())
66 .secrets([
67 (app_id.name.to_owned(), app_id.secret_configuration()),
68 (
69 app_secret.name.to_owned(),
70 app_secret.secret_configuration(),
71 ),
72 ]),
73 ),
74 )
75 .concurrency(one_workflow_per_non_main_branch_and_token("extension-bump"))
76 .add_env(("CARGO_TERM_COLOR", "always"))
77 .add_env(("RUST_BACKTRACE", 1))
78 .add_env(("CARGO_INCREMENTAL", 0))
79 .add_env((
80 "ZED_EXTENSION_CLI_SHA",
81 extension_tests::ZED_EXTENSION_CLI_SHA,
82 ))
83 .add_job(check_version_changed.name, check_version_changed.job)
84 .add_job(bump_version.name, bump_version.job)
85 .add_job(create_label.name, create_label.job)
86 .add_job(trigger_release.name, trigger_release.job)
87}
88
89fn extension_job_defaults() -> Defaults {
90 Defaults::default().run(
91 RunDefaults::default()
92 .shell(BASH_SHELL)
93 .working_directory("${{ inputs.working-directory }}"),
94 )
95}
96
97fn check_version_changed() -> (NamedJob, StepOutput, StepOutput) {
98 let (compare_versions, version_changed, current_version) = compare_versions();
99
100 let job = Job::default()
101 .defaults(extension_job_defaults())
102 .with_repository_owner_guard()
103 .outputs([
104 (version_changed.name.to_owned(), version_changed.to_string()),
105 (
106 current_version.name.to_string(),
107 current_version.to_string(),
108 ),
109 ])
110 .runs_on(runners::LINUX_SMALL)
111 .timeout_minutes(1u32)
112 .add_step(steps::checkout_repo().with_full_history())
113 .add_step(compare_versions);
114
115 (named::job(job), version_changed, current_version)
116}
117
118fn create_version_label(
119 dependencies: &[&NamedJob],
120 version_changed_output: &JobOutput,
121 current_version: &JobOutput,
122 app_id: &WorkflowSecret,
123 app_secret: &WorkflowSecret,
124) -> (NamedJob, StepOutput) {
125 let (generate_token, generated_token) =
126 generate_token(&app_id.to_string(), &app_secret.to_string(), None);
127 let (determine_tag_step, tag) = determine_tag(current_version);
128 let job = steps::dependant_job(dependencies)
129 .defaults(extension_job_defaults())
130 .cond(Expression::new(format!(
131 "{DEFAULT_REPOSITORY_OWNER_GUARD} && github.event_name == 'push' && \
132 github.ref == 'refs/heads/main' && {version_changed} == 'true'",
133 version_changed = version_changed_output.expr(),
134 )))
135 .outputs([(tag.name.to_owned(), tag.to_string())])
136 .runs_on(runners::LINUX_SMALL)
137 .timeout_minutes(1u32)
138 .add_step(generate_token)
139 .add_step(steps::checkout_repo())
140 .add_step(determine_tag_step)
141 .add_step(create_version_tag(&tag, generated_token));
142
143 (named::job(job), tag)
144}
145
146fn create_version_tag(tag: &StepOutput, generated_token: StepOutput) -> Step<Use> {
147 named::uses("actions", "github-script", "v7").with(
148 Input::default()
149 .add(
150 "script",
151 formatdoc! {r#"
152 github.rest.git.createRef({{
153 owner: context.repo.owner,
154 repo: context.repo.repo,
155 ref: 'refs/tags/{tag}',
156 sha: context.sha
157 }})"#
158 },
159 )
160 .add("github-token", generated_token.to_string()),
161 )
162}
163
164fn determine_tag(current_version: &JobOutput) -> (Step<Run>, StepOutput) {
165 let step = named::bash(formatdoc! {r#"
166 EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
167
168 if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then
169 TAG="v${{CURRENT_VERSION}}"
170 else
171 TAG="${{EXTENSION_ID}}-v${{CURRENT_VERSION}}"
172 fi
173
174 echo "tag=${{TAG}}" >> "$GITHUB_OUTPUT"
175 "#})
176 .id("determine-tag")
177 .add_env(("CURRENT_VERSION", current_version.to_string()))
178 .add_env(("WORKING_DIR", "${{ inputs.working-directory }}"));
179
180 let tag = StepOutput::new(&step, "tag");
181 (step, tag)
182}
183
184/// Compares the current and previous commit and checks whether versions changed inbetween.
185pub(crate) fn compare_versions() -> (Step<Run>, StepOutput, StepOutput) {
186 let check_needs_bump = named::bash(formatdoc! {
187 r#"
188 CURRENT_VERSION="$({VERSION_CHECK})"
189
190 if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
191 PR_FORK_POINT="$(git merge-base origin/main HEAD)"
192 git checkout "$PR_FORK_POINT"
193 else
194 git checkout "$(git log -1 --format=%H)"~1
195 fi
196
197 PARENT_COMMIT_VERSION="$({VERSION_CHECK})"
198
199 [[ "$CURRENT_VERSION" == "$PARENT_COMMIT_VERSION" ]] && \
200 echo "version_changed=false" >> "$GITHUB_OUTPUT" || \
201 echo "version_changed=true" >> "$GITHUB_OUTPUT"
202
203 echo "current_version=${{CURRENT_VERSION}}" >> "$GITHUB_OUTPUT"
204 "#
205 })
206 .id("compare-versions-check");
207
208 let version_changed = StepOutput::new(&check_needs_bump, "version_changed");
209 let current_version = StepOutput::new(&check_needs_bump, "current_version");
210
211 (check_needs_bump, version_changed, current_version)
212}
213
214fn bump_extension_version(
215 dependencies: &[&NamedJob],
216 current_version: &JobOutput,
217 bump_type: &WorkflowInput,
218 version_changed_output: &JobOutput,
219 force_bump_output: &WorkflowInput,
220 app_id: &WorkflowSecret,
221 app_secret: &WorkflowSecret,
222) -> NamedJob {
223 let (generate_token, generated_token) =
224 generate_token(&app_id.to_string(), &app_secret.to_string(), None);
225 let (bump_version, _new_version, title, body, branch_name) =
226 bump_version(current_version, bump_type);
227
228 let job = steps::dependant_job(dependencies)
229 .defaults(extension_job_defaults())
230 .cond(Expression::new(format!(
231 "{DEFAULT_REPOSITORY_OWNER_GUARD} &&\n({force_bump} == true || {version_changed} == 'false')",
232 force_bump = force_bump_output.expr(),
233 version_changed = version_changed_output.expr(),
234 )))
235 .runs_on(runners::LINUX_SMALL)
236 .timeout_minutes(5u32)
237 .add_step(generate_token)
238 .add_step(steps::checkout_repo())
239 .add_step(cache_rust_dependencies_namespace())
240 .add_step(install_bump_2_version())
241 .add_step(bump_version)
242 .add_step(create_pull_request(
243 title,
244 body,
245 generated_token,
246 branch_name,
247 ));
248
249 named::job(job)
250}
251
252pub(crate) fn generate_token(
253 app_id_source: &str,
254 app_secret_source: &str,
255 repository_target: Option<RepositoryTarget>,
256) -> (Step<Use>, StepOutput) {
257 let step = named::uses("actions", "create-github-app-token", "v2")
258 .id("generate-token")
259 .add_with(
260 Input::default()
261 .add("app-id", app_id_source)
262 .add("private-key", app_secret_source)
263 .when_some(
264 repository_target,
265 |input,
266 RepositoryTarget {
267 owner,
268 repositories,
269 permissions,
270 }| {
271 input
272 .when_some(owner, |input, owner| input.add("owner", owner))
273 .when_some(repositories, |input, repositories| {
274 input.add("repositories", repositories)
275 })
276 .when_some(permissions, |input, permissions| {
277 permissions
278 .into_iter()
279 .fold(input, |input, (permission, level)| {
280 input.add(
281 permission,
282 serde_json::to_value(&level).unwrap_or_default(),
283 )
284 })
285 })
286 },
287 ),
288 );
289
290 let generated_token = StepOutput::new(&step, "token");
291
292 (step, generated_token)
293}
294
295fn install_bump_2_version() -> Step<Run> {
296 named::run(
297 runners::Platform::Linux,
298 "pip install bump2version --break-system-packages",
299 )
300}
301
302fn bump_version(
303 current_version: &JobOutput,
304 bump_type: &WorkflowInput,
305) -> (Step<Run>, StepOutput, StepOutput, StepOutput, StepOutput) {
306 let step = named::bash(formatdoc! {r#"
307 BUMP_FILES=("extension.toml")
308 if [[ -f "Cargo.toml" ]]; then
309 BUMP_FILES+=("Cargo.toml")
310 fi
311
312 bump2version \
313 --search "version = \"{{current_version}}"\" \
314 --replace "version = \"{{new_version}}"\" \
315 --current-version "$OLD_VERSION" \
316 --no-configured-files "$BUMP_TYPE" "${{BUMP_FILES[@]}}"
317
318 if [[ -f "Cargo.toml" ]]; then
319 cargo update --workspace
320 fi
321
322 NEW_VERSION="$({VERSION_CHECK})"
323 EXTENSION_ID="$(sed -n 's/^id = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
324 EXTENSION_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' < extension.toml | head -1 | tr -d '[:space:]')"
325
326 if [[ "$WORKING_DIR" == "." || -z "$WORKING_DIR" ]]; then
327 {{
328 echo "title=Bump version to ${{NEW_VERSION}}";
329 echo "body=This PR bumps the version of this extension to v${{NEW_VERSION}}";
330 echo "branch_name=zed-zippy-autobump";
331 }} >> "$GITHUB_OUTPUT"
332 else
333 {{
334 echo "title=${{EXTENSION_ID}}: Bump to v${{NEW_VERSION}}";
335 echo "body<<EOF";
336 echo "This PR bumps the version of the ${{EXTENSION_NAME}} extension to v${{NEW_VERSION}}.";
337 echo "";
338 echo "Release Notes:";
339 echo "";
340 echo "- N/A";
341 echo "EOF";
342 echo "branch_name=zed-zippy-${{EXTENSION_ID}}-autobump";
343 }} >> "$GITHUB_OUTPUT"
344 fi
345
346 echo "new_version=${{NEW_VERSION}}" >> "$GITHUB_OUTPUT"
347 "#
348 })
349 .id("bump-version")
350 .add_env(("OLD_VERSION", current_version.to_string()))
351 .add_env(("BUMP_TYPE", bump_type.to_string()))
352 .add_env(("WORKING_DIR", "${{ inputs.working-directory }}"));
353
354 let new_version = StepOutput::new(&step, "new_version");
355 let title = StepOutput::new(&step, "title");
356 let body = StepOutput::new(&step, "body");
357 let branch_name = StepOutput::new(&step, "branch_name");
358 (step, new_version, title, body, branch_name)
359}
360
361fn create_pull_request(
362 title: StepOutput,
363 body: StepOutput,
364 generated_token: StepOutput,
365 branch_name: StepOutput,
366) -> Step<Use> {
367 named::uses("peter-evans", "create-pull-request", "v7").with(
368 Input::default()
369 .add("title", title.to_string())
370 .add("body", body.to_string())
371 .add("commit-message", title.to_string())
372 .add("branch", branch_name.to_string())
373 .add(
374 "committer",
375 "zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>",
376 )
377 .add("base", "main")
378 .add("delete-branch", true)
379 .add("token", generated_token.to_string())
380 .add("sign-commits", true)
381 .add("assignees", Context::github().actor().to_string()),
382 )
383}
384
385fn trigger_release(
386 dependencies: &[&NamedJob],
387 tag: JobOutput,
388 app_id: &WorkflowSecret,
389 app_secret: &WorkflowSecret,
390) -> NamedJob {
391 let extension_registry = RepositoryTarget::new("zed-industries", &["extensions"]);
392 let (generate_token, generated_token) = generate_token(
393 &app_id.to_string(),
394 &app_secret.to_string(),
395 Some(extension_registry),
396 );
397 let (get_extension_id, extension_id) = get_extension_id();
398
399 let job = dependant_job(dependencies)
400 .defaults(extension_job_defaults())
401 .with_repository_owner_guard()
402 .runs_on(runners::LINUX_SMALL)
403 .add_step(generate_token)
404 .add_step(checkout_repo())
405 .add_step(get_extension_id)
406 .add_step(release_action(extension_id, tag, generated_token));
407
408 named::job(job)
409}
410
411fn get_extension_id() -> (Step<Run>, StepOutput) {
412 let step = named::bash(indoc! {
413 r#"
414 EXTENSION_ID="$(sed -n 's/id = \"\(.*\)\"/\1/p' < extension.toml)"
415
416 echo "extension_id=${EXTENSION_ID}" >> "$GITHUB_OUTPUT"
417 "#})
418 .id("get-extension-id");
419
420 let extension_id = StepOutput::new(&step, "extension_id");
421
422 (step, extension_id)
423}
424
425fn release_action(
426 extension_id: StepOutput,
427 tag: JobOutput,
428 generated_token: StepOutput,
429) -> Step<Use> {
430 named::uses(
431 "zed-extensions",
432 "update-action",
433 "543925fc45da8866b0d017218a656c8a3296ed3f",
434 )
435 .add_with(("extension-name", extension_id.to_string()))
436 .add_with(("push-to", "zed-industries/extensions"))
437 .add_with(("tag", tag.to_string()))
438 .add_env(("COMMITTER_TOKEN", generated_token.to_string()))
439}
440
441fn extension_workflow_secrets() -> (WorkflowSecret, WorkflowSecret) {
442 let app_id = WorkflowSecret::new("app-id", "The app ID used to create the PR");
443 let app_secret =
444 WorkflowSecret::new("app-secret", "The app secret for the corresponding app ID");
445
446 (app_id, app_secret)
447}
448
449pub(crate) struct RepositoryTarget {
450 owner: Option<String>,
451 repositories: Option<String>,
452 permissions: Option<Vec<(String, Level)>>,
453}
454
455impl RepositoryTarget {
456 pub fn new<T: ToString>(owner: T, repositories: &[&str]) -> Self {
457 Self {
458 owner: Some(owner.to_string()),
459 repositories: Some(repositories.join("\n")),
460 permissions: None,
461 }
462 }
463
464 pub fn current() -> Self {
465 Self {
466 owner: None,
467 repositories: None,
468 permissions: None,
469 }
470 }
471
472 pub fn permissions(self, permissions: impl Into<Vec<(String, Level)>>) -> Self {
473 Self {
474 permissions: Some(permissions.into()),
475 ..self
476 }
477 }
478}