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