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