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