1use anyhow::Result;
2
3pub struct PatchOpts {
4 pub binary: String,
5 pub namespaces: Vec<String>,
9 pub docker_namespace: String,
10 pub orb_dir: String,
11 pub build_workflow: String,
12 pub release_workflow: String,
13 pub requires_job: Option<String>,
14 pub release_after_job: String,
15 pub orb_tools_version: String,
16 pub docker_orb_version: String,
17 pub docker_context: String,
18 pub orb_context: String,
19 pub private_namespaces: Vec<String>,
24 pub mcp: bool,
25}
26
27pub struct PatchReport {
28 pub insertions: Vec<String>,
29 pub skipped: Vec<String>,
30}
31
32pub fn patch_build(content: &str, opts: &PatchOpts) -> (String, PatchReport) {
35 let mut report = PatchReport {
36 insertions: vec![],
37 skipped: vec![],
38 };
39 let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
40
41 let orb_entry = format!(" orb-tools: circleci/orb-tools@{}", opts.orb_tools_version);
43 if content.contains("orb-tools:") {
44 report.skipped.push("orb-tools orb".to_string());
45 } else if let Some(pos) = find_section_end(&lines, "orbs:") {
46 lines.insert(pos, orb_entry);
47 report.insertions.push("orb-tools orb".to_string());
48 }
49
50 let jobs_present = content.contains("build-binary:") && content.contains("regenerate-orb:");
52 if jobs_present {
53 report
54 .skipped
55 .push("build-binary and regenerate-orb jobs".to_string());
56 } else {
57 let build_block = build_binary_job(opts);
58 let regen_block = regenerate_orb_job(opts);
59 if let Some(pos) = find_section_end(&lines, "jobs:") {
60 for (i, l) in build_block.iter().enumerate() {
61 lines.insert(pos + i, l.clone());
62 }
63 let after_build = pos + build_block.len();
64 for (i, l) in regen_block.iter().enumerate() {
65 lines.insert(after_build + i, l.clone());
66 }
67 } else {
68 if let Some(wf_pos) = find_top_level(&lines, "workflows:") {
70 lines.insert(wf_pos, String::new());
71 lines.insert(wf_pos, String::new());
72 let regen_len = regen_block.len();
74 for (i, _) in regen_block.iter().rev().enumerate() {
75 lines.insert(wf_pos, regen_block[regen_len - 1 - i].clone());
76 }
77 let build_len = build_block.len();
78 for (i, _) in build_block.iter().rev().enumerate() {
79 lines.insert(wf_pos, build_block[build_len - 1 - i].clone());
80 }
81 lines.insert(wf_pos, "jobs:".to_string());
82 }
83 }
84 report
85 .insertions
86 .push("build-binary and regenerate-orb jobs".to_string());
87 }
88
89 if content.contains("orb-tools/pack:") {
91 report.skipped.push("workflow steps".to_string());
92 } else {
93 let step_block = pack_validate_steps(opts);
94 if let Some(pos) = find_workflow_jobs_end(&lines, &opts.build_workflow) {
95 for (i, l) in step_block.iter().enumerate() {
96 lines.insert(pos + i, l.clone());
97 }
98 }
99 report.insertions.push("workflow steps".to_string());
100 }
101
102 let mut output = lines.join("\n");
103 if content.ends_with('\n') {
104 output.push('\n');
105 }
106 (output, report)
107}
108
109pub fn patch_release(content: &str, opts: &PatchOpts) -> (String, PatchReport) {
112 let mut report = PatchReport {
113 insertions: vec![],
114 skipped: vec![],
115 };
116 let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
117
118 let docker_entry = format!(" docker: circleci/docker@{}", opts.docker_orb_version);
121 let orb_entry = format!(" orb-tools: circleci/orb-tools@{}", opts.orb_tools_version);
122 let has_docker_orb = content.contains(" docker: circleci/");
123 let has_orb_tools = content.contains(" orb-tools: circleci/");
124
125 if has_docker_orb && has_orb_tools {
126 report.skipped.push("docker and orb-tools orbs".to_string());
127 } else {
128 if let Some(pos) = find_section_end(&lines, "orbs:") {
129 let mut insert_pos = pos;
130 if !has_orb_tools {
131 lines.insert(insert_pos, orb_entry);
132 insert_pos += 1;
133 }
134 if !has_docker_orb {
135 lines.insert(insert_pos, docker_entry);
136 }
137 report
138 .insertions
139 .push("docker and orb-tools orbs".to_string());
140 }
141 }
142
143 let has_binary_release = content.contains("build-binary-release:");
145 let missing_ensure_ns: Vec<&String> = opts
146 .namespaces
147 .iter()
148 .filter(|ns| !content.contains(&format!(" ensure-orb-registered-{ns}:")))
149 .collect();
150 let has_container = content.contains("build-container:");
151 if has_binary_release && missing_ensure_ns.is_empty() && has_container {
152 report.skipped.push(
153 "release jobs (build-binary-release, ensure-orb-registered, build-container)"
154 .to_string(),
155 );
156 } else {
157 if let Some(pos) = find_section_end(&lines, "jobs:") {
158 let mut insert_pos = pos;
159 if !has_binary_release {
160 let binary_block = build_binary_release_job(opts);
161 let binary_len = binary_block.len();
162 for (i, l) in binary_block.into_iter().enumerate() {
163 lines.insert(insert_pos + i, l);
164 }
165 insert_pos += binary_len;
166 }
167 for ns in &missing_ensure_ns {
168 let ensure_block = ensure_orb_registered_job_for(
169 ns,
170 &opts.binary,
171 opts.private_namespaces.contains(ns),
172 );
173 let ensure_len = ensure_block.len();
174 for (i, l) in ensure_block.into_iter().enumerate() {
175 lines.insert(insert_pos + i, l);
176 }
177 insert_pos += ensure_len;
178 }
179 if !has_container {
180 let container_block = build_container_job(opts);
181 for (i, l) in container_block.into_iter().enumerate() {
182 lines.insert(insert_pos + i, l);
183 }
184 }
185 } else if let Some(wf_pos) = find_top_level(&lines, "workflows:") {
186 lines.insert(wf_pos, String::new());
188 lines.insert(wf_pos, String::new());
189 let container_block = build_container_job(opts);
190 let container_len = container_block.len();
191 for i in 0..container_len {
192 lines.insert(wf_pos, container_block[container_len - 1 - i].clone());
193 }
194 for ns in opts.namespaces.iter().rev() {
196 if missing_ensure_ns.contains(&ns) {
197 let ensure_block = ensure_orb_registered_job_for(
198 ns,
199 &opts.binary,
200 opts.private_namespaces.contains(ns),
201 );
202 let ensure_len = ensure_block.len();
203 for i in 0..ensure_len {
204 lines.insert(wf_pos, ensure_block[ensure_len - 1 - i].clone());
205 }
206 }
207 }
208 let binary_block = build_binary_release_job(opts);
209 let binary_len = binary_block.len();
210 for i in 0..binary_len {
211 lines.insert(wf_pos, binary_block[binary_len - 1 - i].clone());
212 }
213 lines.insert(wf_pos, "jobs:".to_string());
214 }
215 report.insertions.push(
216 "release jobs (build-binary-release, ensure-orb-registered, build-container)"
217 .to_string(),
218 );
219 }
220
221 let all_ns_steps_present = opts.namespaces.iter().all(|ns| {
223 content.contains(&format!(" - ensure-orb-registered-{ns}:"))
224 && content.contains(&format!("name: publish-orb-{ns}"))
225 });
226 if content.contains("pack-orb-release")
227 && content.contains(" - build-binary-release:")
228 && content.contains(" - build-container:")
229 && all_ns_steps_present
230 {
231 report.skipped.push("release workflow steps".to_string());
232 } else {
233 let step_block = release_workflow_steps(opts);
234 if let Some(pos) = find_workflow_jobs_end(&lines, &opts.release_workflow) {
235 for (i, l) in step_block.iter().enumerate() {
236 lines.insert(pos + i, l.clone());
237 }
238 }
239 report.insertions.push("release workflow steps".to_string());
240 }
241
242 let mut output = lines.join("\n");
243 if content.ends_with('\n') {
244 output.push('\n');
245 }
246 (output, report)
247}
248
249pub fn apply_patches(
251 ci_dir: &std::path::Path,
252 opts: &PatchOpts,
253 dry_run: bool,
254) -> Result<Vec<String>> {
255 let mut summary = vec![];
256
257 for (filename, patch_fn) in &[
258 (
259 "config.yml",
260 patch_build as fn(&str, &PatchOpts) -> (String, PatchReport),
261 ),
262 (
263 "release.yml",
264 patch_release as fn(&str, &PatchOpts) -> (String, PatchReport),
265 ),
266 ] {
267 let path = ci_dir.join(filename);
268 if !path.exists() {
269 summary.push(format!("{filename}: not found, skipped"));
270 continue;
271 }
272 let content = std::fs::read_to_string(&path)?;
273 let (patched, report) = patch_fn(&content, opts);
274
275 for ins in &report.insertions {
276 summary.push(format!("{filename}: inserted {ins}"));
277 }
278 for sk in &report.skipped {
279 summary.push(format!("{filename}: skipped {sk} (already present)"));
280 }
281
282 if !dry_run && patched != content {
283 std::fs::write(&path, &patched)?;
284 }
285 }
286
287 Ok(summary)
288}
289
290fn find_top_level(lines: &[String], header: &str) -> Option<usize> {
293 lines.iter().position(|l| l.trim_end() == header)
294}
295
296fn find_section_end(lines: &[String], header: &str) -> Option<usize> {
299 let start = find_top_level(lines, header)?;
300 for (i, l) in lines.iter().enumerate().skip(start + 1) {
301 if !l.is_empty() && !l.starts_with(' ') && !l.starts_with('\t') && !l.starts_with('#') {
302 return Some(i);
303 }
304 }
305 Some(lines.len())
306}
307
308fn find_workflow_jobs_end(lines: &[String], workflow: &str) -> Option<usize> {
310 let wf_line = format!(" {workflow}:");
311 let wf_idx = lines
312 .iter()
313 .position(|l| l.trim_end() == wf_line.trim_end())?;
314
315 let mut jobs_idx = None;
317 for (i, line) in lines.iter().enumerate().skip(wf_idx + 1) {
318 let l = line.trim_end();
319 if !line.starts_with(" ") {
320 break;
321 }
322 if l == " jobs:" || l == " jobs:" {
323 jobs_idx = Some(i);
324 break;
325 }
326 }
327 let jobs_start = jobs_idx?;
328
329 for (i, l) in lines.iter().enumerate().skip(jobs_start + 1) {
331 if l.trim_end().is_empty() {
332 continue;
333 }
334 let indent = l.len() - l.trim_start().len();
336 if indent <= 2 {
337 return Some(i);
338 }
339 }
340 Some(lines.len())
341}
342
343fn build_binary_job(opts: &PatchOpts) -> Vec<String> {
344 let binary = &opts.binary;
345 vec![
346 " build-binary:".to_string(),
347 " docker:".to_string(),
348 " - image: rust:latest".to_string(),
349 " steps:".to_string(),
350 " - checkout".to_string(),
351 " - run:".to_string(),
352 " name: Build binary".to_string(),
353 " command: cargo build --release".to_string(),
354 " - persist_to_workspace:".to_string(),
355 " root: target/release".to_string(),
356 format!(" paths: [{binary}]"),
357 ]
358}
359
360fn regenerate_orb_job(opts: &PatchOpts) -> Vec<String> {
361 let binary = &opts.binary;
362 let orb_dir = &opts.orb_dir;
363 let mut lines = vec![
366 " regenerate-orb:".to_string(),
367 " docker:".to_string(),
368 " - image: jerusdp/gen-circleci-orb:latest".to_string(),
369 " steps:".to_string(),
370 " - checkout".to_string(),
371 " - attach_workspace:".to_string(),
372 " at: /tmp/bin".to_string(),
373 " - run:".to_string(),
374 " name: Regenerate orb source".to_string(),
375 " command: |".to_string(),
376 " export PATH=\"/tmp/bin:$PATH\"".to_string(),
377 " gen-circleci-orb generate \\".to_string(),
378 format!(" --binary {binary} \\"),
379 ];
380 for ns in &opts.namespaces {
382 lines.push(format!(" --orb-namespace {ns} \\"));
383 }
384 lines.push(format!(" --orb-dir {orb_dir}"));
385 lines
386}
387
388fn pack_validate_steps(opts: &PatchOpts) -> Vec<String> {
389 let orb_dir = &opts.orb_dir;
390 let mut steps = vec![];
391
392 steps.push(" - build-binary:".to_string());
394 if let Some(req) = &opts.requires_job {
395 steps.push(format!(" requires: [{req}]"));
396 }
397
398 steps.push(" - regenerate-orb:".to_string());
400 steps.push(" requires: [build-binary]".to_string());
401
402 steps.push(" - orb-tools/pack:".to_string());
404 steps.push(" name: pack-orb".to_string());
405 steps.push(format!(" source_dir: {orb_dir}/src"));
406 steps.push(" requires: [regenerate-orb]".to_string());
407
408 steps.push(" - orb-tools/review:".to_string());
410 steps.push(" name: review-orb".to_string());
411 steps.push(format!(" source_dir: {orb_dir}/src"));
412 steps.push(" requires: [pack-orb]".to_string());
413
414 steps
415}
416
417fn build_binary_release_job(opts: &PatchOpts) -> Vec<String> {
418 let binary = &opts.binary;
419 vec![
420 " build-binary-release:".to_string(),
421 " docker:".to_string(),
422 " - image: rust:latest".to_string(),
423 " steps:".to_string(),
424 " - checkout".to_string(),
425 " - run:".to_string(),
426 " name: Build release binary".to_string(),
427 format!(" command: cargo build --release -p {binary}"),
428 " - persist_to_workspace:".to_string(),
429 " root: target/release".to_string(),
430 format!(" paths: [{binary}]"),
431 ]
432}
433
434fn ensure_orb_registered_job_for(ns: &str, binary: &str, private: bool) -> Vec<String> {
440 let create_flags = if private {
442 "--private --no-prompt"
443 } else {
444 "--no-prompt"
445 };
446 vec![
447 format!(" ensure-orb-registered-{ns}:"),
448 " executor: orb-tools/default".to_string(),
449 " steps:".to_string(),
450 " - run:".to_string(),
451 " name: Ensure orb is registered".to_string(),
452 " command: |".to_string(),
453 " circleci setup --token \"${CIRCLE_TOKEN}\" --host https://circleci.com --no-prompt".to_string(),
454 format!(" if ! circleci orb info {ns}/{binary} > /dev/null 2>&1; then"),
455 format!(" create_output=$(circleci orb create {ns}/{binary} {create_flags} 2>&1)"),
456 " create_exit=$?".to_string(),
457 " echo \"${create_output}\"".to_string(),
458 " if [ \"${create_exit}\" -ne 0 ] && ! echo \"${create_output}\" | grep -q \"already exists\"; then".to_string(),
459 " exit \"${create_exit}\"".to_string(),
460 " fi".to_string(),
461 " fi".to_string(),
462 " echo \"Orb is registered.\"".to_string(),
463 ]
464}
465
466fn build_container_job(opts: &PatchOpts) -> Vec<String> {
467 let binary = &opts.binary;
468 let docker_ns = &opts.docker_namespace;
469 let orb_dir = &opts.orb_dir;
470 let version_var = format!("CRATE_VERSION_{}", binary.to_uppercase().replace('-', "_"));
475 vec![
476 " build-container:".to_string(),
477 " docker:".to_string(),
478 " - image: cimg/base:stable".to_string(),
479 " steps:".to_string(),
480 " - checkout".to_string(),
481 " - setup_remote_docker".to_string(),
482 " - attach_workspace:".to_string(),
483 " at: /tmp/release-versions".to_string(),
484 " - attach_workspace:".to_string(),
485 " at: /tmp/bin".to_string(),
486 " - run:".to_string(),
487 " name: Build and push Docker image".to_string(),
488 " command: |".to_string(),
489 " source /tmp/release-versions/versions.env".to_string(),
490 format!(" VERSION=${{{version_var}}}"),
491 format!(" cp /tmp/bin/{binary} {orb_dir}/{binary}"),
492 format!(" docker build -t {docker_ns}/{binary}:${{VERSION}} -t {docker_ns}/{binary}:latest {orb_dir}"),
493 " echo \"${DOCKERHUB_PASSWORD}\" | docker login -u \"${DOCKERHUB_USERNAME}\" --password-stdin".to_string(),
494 format!(" docker push {docker_ns}/{binary}:${{VERSION}}"),
495 format!(" docker push {docker_ns}/{binary}:latest"),
496 ]
497}
498
499fn release_workflow_steps(opts: &PatchOpts) -> Vec<String> {
500 let binary = &opts.binary;
501 let docker_ctx = &opts.docker_context;
502 let orb_ctx = &opts.orb_context;
503 let orb_dir = &opts.orb_dir;
504 let requires = format!("[{}]", opts.release_after_job);
508 let version_var = format!("CRATE_VERSION_{}", binary.to_uppercase().replace('-', "_"));
511 let mut steps = vec![];
512
513 steps.push(" - build-binary-release:".to_string());
515 steps.push(format!(" requires: {requires}"));
516
517 steps.push(" - orb-tools/pack:".to_string());
519 steps.push(" name: pack-orb-release".to_string());
520 steps.push(format!(" source_dir: {orb_dir}/src"));
521 steps.push(format!(" requires: {requires}"));
522
523 steps.push(" - build-container:".to_string());
525 steps.push(" requires: [build-binary-release]".to_string());
526 steps.push(format!(" context: [{docker_ctx}]"));
527
528 for ns in &opts.namespaces {
533 steps.push(format!(" - ensure-orb-registered-{ns}:"));
535 steps.push(format!(" requires: {requires}"));
536 steps.push(format!(" context: [{orb_ctx}]"));
537
538 steps.push(" - orb-tools/publish:".to_string());
541 steps.push(format!(" name: publish-orb-{ns}"));
542 steps.push(" pre-steps:".to_string());
543 steps.push(" - attach_workspace:".to_string());
544 steps.push(" at: /tmp/release-versions".to_string());
545 steps.push(" - run:".to_string());
546 steps.push(" name: Export orb version as CIRCLE_TAG".to_string());
547 steps.push(" command: |".to_string());
548 steps.push(" source /tmp/release-versions/versions.env".to_string());
549 steps.push(format!(
551 " echo \"export CIRCLE_TAG=v${{{version_var}}}\" >> \"$BASH_ENV\""
552 ));
553 steps.push(format!(" orb_name: {ns}/{binary}"));
554 steps.push(" pub_type: production".to_string());
555 steps.push(" vcs_type: github".to_string());
556 steps.push(format!(
557 " requires: [build-container, pack-orb-release, ensure-orb-registered-{ns}]"
558 ));
559 steps.push(format!(" context: [{orb_ctx}]"));
560 }
561
562 steps
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 fn make_opts() -> PatchOpts {
570 PatchOpts {
571 binary: "mytool".to_string(),
572 namespaces: vec!["my-org".to_string()],
573 docker_namespace: "my-docker-org".to_string(),
574 orb_dir: "orb".to_string(),
575 build_workflow: "validation".to_string(),
576 release_workflow: "release".to_string(),
577 requires_job: Some("common-tests".to_string()),
578 release_after_job: "approve-release".to_string(),
581 orb_tools_version: "12.3.3".to_string(),
582 docker_orb_version: "3.0.1".to_string(),
583 docker_context: "docker".to_string(),
584 orb_context: "orb-publishing".to_string(),
585 private_namespaces: vec![],
586 mcp: false,
587 }
588 }
589
590 fn make_opts_two_ns() -> PatchOpts {
591 PatchOpts {
592 namespaces: vec!["my-org".to_string(), "other-org".to_string()],
593 ..make_opts()
594 }
595 }
596
597 const BUILD_FIXTURE: &str = "\
598version: 2.1
599
600orbs:
601 toolkit: jerus-org/circleci-toolkit@6.0.0
602
603jobs:
604 some-job:
605 docker:
606 - image: cimg/base:stable
607 steps:
608 - run: echo hello
609
610workflows:
611 validation:
612 jobs:
613 - some-job
614";
615
616 const BUILD_FIXTURE_NO_JOBS: &str = "\
618version: 2.1
619
620parameters:
621 min_rust_version:
622 type: string
623 default: \"1.87\"
624
625orbs:
626 toolkit: jerus-org/circleci-toolkit@6.0.0
627
628workflows:
629 validation:
630 jobs:
631 - toolkit/required_builds:
632 min_rust_version: << pipeline.parameters.min_rust_version >>
633
634 - toolkit/common_tests:
635 min_rust_version: << pipeline.parameters.min_rust_version >>
636
637 - toolkit/idiomatic_rust:
638 filters:
639 branches:
640 ignore: main
641";
642
643 const RELEASE_FIXTURE: &str = "\
644version: 2.1
645
646orbs:
647 toolkit: jerus-org/circleci-toolkit@6.0.0
648
649jobs:
650 release-mytool:
651 docker:
652 - image: cimg/rust:stable
653 steps:
654 - checkout
655
656workflows:
657 release:
658 jobs:
659 - release-mytool
660";
661
662 const RELEASE_FIXTURE_NO_JOBS: &str = "\
665version: 2.1
666
667orbs:
668 toolkit: jerus-org/circleci-toolkit@6.0.0
669
670workflows:
671 release:
672 jobs:
673 - toolkit/release_crate:
674 name: release-mytool
675 context: [release]
676 - toolkit/release_prlog:
677 requires: [release-mytool]
678 context: [release]
679";
680
681 const RELEASE_FIXTURE_WITH_RELEASE_CRATE: &str = "\
684version: 2.1
685
686orbs:
687 toolkit: jerus-org/circleci-toolkit@6.0.0
688
689workflows:
690 release:
691 jobs:
692 - approve-release:
693 type: approval
694 - toolkit/release_crate:
695 name: release-mytool
696 requires: [approve-release]
697 context: [release]
698 - toolkit/release_prlog:
699 requires: [release-mytool]
700 context: [release]
701";
702
703 #[test]
706 fn patch_build_wires_workflow_steps_when_no_jobs_section() {
707 let (output, report) = patch_build(BUILD_FIXTURE_NO_JOBS, &make_opts());
708 assert!(
709 output.contains("regenerate-orb:"),
710 "missing job def:\n{output}"
711 );
712 assert!(
713 output.contains("orb-tools/pack:"),
714 "pack step not wired into workflow:\n{output}"
715 );
716 assert!(
717 output.contains("orb-tools/review:"),
718 "review step not wired into workflow:\n{output}"
719 );
720 assert!(
722 report
723 .insertions
724 .iter()
725 .any(|s| s.contains("regenerate-orb")),
726 "report missing regenerate-orb"
727 );
728 assert!(
729 report.insertions.iter().any(|s| s.contains("workflow")),
730 "report missing workflow steps"
731 );
732 }
733
734 #[test]
737 fn patch_build_adds_orb_tools_to_orbs_section() {
738 let (output, report) = patch_build(BUILD_FIXTURE, &make_opts());
739 assert!(
740 output.contains("orb-tools: circleci/orb-tools@12.3.3"),
741 "missing orb-tools entry:\n{output}"
742 );
743 assert!(
744 report.insertions.iter().any(|s| s.contains("orb-tools")),
745 "report missing orb-tools insertion"
746 );
747 }
748
749 #[test]
750 fn patch_build_adds_build_binary_job() {
751 let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
752 assert!(
753 output.contains(" build-binary:"),
754 "missing build-binary job:\n{output}"
755 );
756 }
757
758 fn job_block(output: &str, job_name: &str) -> String {
761 let header = format!(" {job_name}:");
762 let mut in_block = false;
763 let mut result = String::new();
764 for line in output.lines() {
765 if line.trim_end() == header.trim_end() {
766 in_block = true;
767 continue;
768 }
769 if in_block {
770 if !line.is_empty() && !line.starts_with(" ") && !line.starts_with('\t') {
773 break;
774 }
775 result.push_str(line);
776 result.push('\n');
777 }
778 }
779 result
780 }
781
782 #[test]
783 fn build_binary_uses_public_rust_image() {
784 let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
785 let block = job_block(&output, "build-binary");
786 assert!(
787 block.contains("rust:latest"),
788 "build-binary must use the public rust:latest image, not a private CI image:\n{block}"
789 );
790 }
791
792 #[test]
793 fn build_binary_runs_cargo_build_release() {
794 let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
795 let block = job_block(&output, "build-binary");
796 assert!(
797 block.contains("cargo build --release"),
798 "build-binary must run cargo build --release:\n{block}"
799 );
800 }
801
802 #[test]
803 fn build_binary_persists_to_workspace() {
804 let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
805 let block = job_block(&output, "build-binary");
806 assert!(
807 block.contains("persist_to_workspace"),
808 "build-binary must persist binary to workspace:\n{block}"
809 );
810 }
811
812 #[test]
813 fn patch_build_adds_regenerate_orb_job() {
814 let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
815 assert!(output.contains("regenerate-orb:"), "missing job:\n{output}");
816 assert!(
818 !output.contains("install-from-binstall-release.sh"),
819 "regenerate-orb must not bootstrap cargo-binstall — use the gen-circleci-orb image:\n{output}"
820 );
821 assert!(
822 !output.contains("cargo-binstall --no-confirm gen-circleci-orb"),
823 "regenerate-orb must not install gen-circleci-orb at runtime:\n{output}"
824 );
825 assert!(
826 output.contains("gen-circleci-orb generate"),
827 "missing generate step:\n{output}"
828 );
829 }
830
831 #[test]
832 fn regenerate_orb_uses_gen_circleci_orb_image_and_attaches_workspace() {
833 let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
834 let block = job_block(&output, "regenerate-orb");
835 assert!(
836 block.contains("jerusdp/gen-circleci-orb:latest"),
837 "regenerate-orb must use the gen-circleci-orb Docker image (gen-circleci-orb is pre-installed):\n{block}"
838 );
839 assert!(
840 block.contains("attach_workspace"),
841 "regenerate-orb must attach workspace to get the target binary:\n{block}"
842 );
843 assert!(
844 !block.contains("cargo-binstall"),
845 "regenerate-orb must not install anything — gen-circleci-orb is in the image:\n{block}"
846 );
847 }
848
849 #[test]
850 fn workflow_build_binary_precedes_regenerate_orb() {
851 let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
852 let bb_pos = output
853 .find(" - build-binary:")
854 .expect("no build-binary workflow step");
855 let regen_pos = output
856 .find(" - regenerate-orb:")
857 .expect("no regenerate-orb workflow step");
858 assert!(
859 bb_pos < regen_pos,
860 "build-binary must appear before regenerate-orb in the workflow"
861 );
862 }
863
864 #[test]
865 fn build_binary_workflow_step_requires_configured_job() {
866 let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
867 let after_bb = output
869 .split(" - build-binary:")
870 .nth(1)
871 .expect("no build-binary workflow step");
872 let step_block = after_bb.split(" - ").next().unwrap_or(after_bb);
873 assert!(
874 step_block.contains("requires: [common-tests]"),
875 "build-binary workflow step must require the configured job:\n{step_block}"
876 );
877 }
878
879 #[test]
880 fn regenerate_orb_workflow_step_requires_build_binary() {
881 let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
882 let after_regen = output
883 .split(" - regenerate-orb:")
884 .nth(1)
885 .expect("no regenerate-orb workflow step");
886 let step_block = after_regen.split(" - ").next().unwrap_or(after_regen);
887 assert!(
888 step_block.contains("requires: [build-binary]"),
889 "regenerate-orb workflow step must require build-binary:\n{step_block}"
890 );
891 }
892
893 #[test]
894 fn patch_build_adds_workflow_steps() {
895 let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
896 assert!(
897 output.contains("orb-tools/pack:"),
898 "missing pack step:\n{output}"
899 );
900 assert!(
901 output.contains("orb-tools/review:"),
902 "missing review step:\n{output}"
903 );
904 assert!(
906 output.contains("source_dir: orb/src"),
907 "pack/review must use source_dir (not source-dir):\n{output}"
908 );
909 assert!(
910 !output.contains("destination-orb-path"),
911 "must not use deprecated destination-orb-path:\n{output}"
912 );
913 }
914
915 #[test]
916 fn patch_build_is_idempotent() {
917 let (first, _) = patch_build(BUILD_FIXTURE, &make_opts());
918 let (second, second_report) = patch_build(&first, &make_opts());
919 assert_eq!(
920 first, second,
921 "second patch changed content — not idempotent"
922 );
923 assert!(
924 second_report
925 .skipped
926 .iter()
927 .any(|s| s.contains("orb-tools")),
928 "expected orb-tools skipped on second run"
929 );
930 assert!(
931 second_report
932 .skipped
933 .iter()
934 .any(|s| s.contains("build-binary")),
935 "expected build-binary and regenerate-orb skipped on second run"
936 );
937 }
938
939 #[test]
940 fn patch_build_job_in_jobs_section_not_workflows() {
941 let (output, _) = patch_build(BUILD_FIXTURE, &make_opts());
942 let jobs_pos = output.find("\njobs:").expect("no jobs: section");
944 let workflows_pos = output.find("\nworkflows:").expect("no workflows: section");
945 let job_def_pos = output.find(" regenerate-orb:").expect("no job def");
946 assert!(
947 job_def_pos > jobs_pos && job_def_pos < workflows_pos,
948 "job definition not in jobs section"
949 );
950 }
951
952 #[test]
955 fn patch_release_adds_docker_and_orb_tools_orbs() {
956 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
957 assert!(
958 output.contains("docker: circleci/docker@3.0.1"),
959 "missing docker orb:\n{output}"
960 );
961 assert!(
962 output.contains("orb-tools: circleci/orb-tools@12.3.3"),
963 "missing orb-tools orb:\n{output}"
964 );
965 }
966
967 #[test]
968 fn patch_release_adds_build_container_job() {
969 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
970 assert!(
971 output.contains("build-container:"),
972 "missing job:\n{output}"
973 );
974 assert!(
975 output.contains("docker build"),
976 "missing docker build step:\n{output}"
977 );
978 assert!(
979 output.contains("docker push"),
980 "missing docker push step:\n{output}"
981 );
982 assert!(
985 !output.contains("${CIRCLE_TAG}"),
986 "must not use CIRCLE_TAG (empty in approval-triggered pipelines):\n{output}"
987 );
988 assert!(
989 output.contains("versions.env"),
990 "must source versions.env to get the release version:\n{output}"
991 );
992 assert!(
993 output.contains("attach_workspace"),
994 "must attach workspace to get versions.env and the binary:\n{output}"
995 );
996 assert!(
997 output.contains("docker login -u \"${DOCKERHUB_USERNAME}\""),
998 "must log in to Docker Hub with DOCKERHUB_USERNAME before pushing:\n{output}"
999 );
1000 assert!(
1001 output.contains("--password-stdin"),
1002 "must use --password-stdin (not -p flag) for Docker login:\n{output}"
1003 );
1004 assert!(
1005 output.contains(":latest"),
1006 "must also push a :latest tag:\n{output}"
1007 );
1008 }
1009
1010 #[test]
1011 fn patch_release_uses_docker_namespace_not_orb_namespace() {
1012 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1013 assert!(
1015 output.contains("docker build -t my-docker-org/mytool"),
1016 "docker build must use docker_namespace:\n{output}"
1017 );
1018 assert!(
1019 output.contains("docker push my-docker-org/mytool"),
1020 "docker push must use docker_namespace:\n{output}"
1021 );
1022 assert!(
1023 !output.contains("docker build -t my-org/"),
1024 "docker build must NOT use orb namespace:\n{output}"
1025 );
1026 }
1027
1028 #[test]
1029 fn patch_release_adds_workflow_steps() {
1030 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1031 assert!(
1033 output.contains("orb-tools/pack:"),
1034 "missing pack step in release workflow:\n{output}"
1035 );
1036 assert!(
1037 output.contains("orb-tools/publish:"),
1038 "missing publish step:\n{output}"
1039 );
1040 assert!(
1041 output.contains("publish-orb-my-org"),
1042 "missing publish job name:\n{output}"
1043 );
1044 assert!(
1045 output.contains("pub_type: production"),
1046 "publish must set pub_type production:\n{output}"
1047 );
1048 assert!(
1049 output.contains("vcs_type: github"),
1050 "publish must set vcs_type (required by orb-tools/publish):\n{output}"
1051 );
1052 }
1053
1054 #[test]
1055 fn patch_release_is_idempotent() {
1056 let (first, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1057 let (second, second_report) = patch_release(&first, &make_opts());
1058 assert_eq!(
1059 first, second,
1060 "second release patch changed content — not idempotent"
1061 );
1062 assert!(
1063 !second_report.skipped.is_empty(),
1064 "expected skipped entries on second run"
1065 );
1066 }
1067
1068 #[test]
1069 fn patch_release_includes_namespace_and_binary_in_publish() {
1070 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1071 assert!(
1073 output.contains("orb_name: my-org/mytool"),
1074 "missing orb_name (underscore):\n{output}"
1075 );
1076 assert!(
1077 !output.contains("orb-path:"),
1078 "must not use deprecated orb-path:\n{output}"
1079 );
1080 }
1081
1082 #[test]
1083 fn patch_release_adds_build_container_job_when_no_jobs_section() {
1084 let (output, _) = patch_release(RELEASE_FIXTURE_NO_JOBS, &make_opts());
1088 assert!(
1089 output.contains("build-binary-release:"),
1090 "build-binary-release job definition missing when no pre-existing jobs section:\n{output}"
1091 );
1092 assert!(
1093 output.contains("build-container:"),
1094 "build-container job definition missing when no pre-existing jobs section:\n{output}"
1095 );
1096 let jobs_pos = output.find("\njobs:").expect("jobs: section not created");
1097 let workflows_pos = output.find("\nworkflows:").expect("no workflows: section");
1098 let binary_release_pos = output
1099 .find(" build-binary-release:")
1100 .expect("no build-binary-release job definition");
1101 let container_pos = output
1102 .find(" build-container:")
1103 .expect("no build-container job definition");
1104 assert!(
1105 binary_release_pos > jobs_pos && binary_release_pos < workflows_pos,
1106 "build-binary-release definition must be in jobs: section, not workflows:\n{output}"
1107 );
1108 assert!(
1109 container_pos > jobs_pos && container_pos < workflows_pos,
1110 "build-container definition must be in jobs: section, not workflows:\n{output}"
1111 );
1112 }
1113
1114 #[test]
1117 fn patch_release_adds_build_binary_release_job() {
1118 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1119 assert!(
1120 output.contains(" build-binary-release:"),
1121 "missing build-binary-release job definition:\n{output}"
1122 );
1123 let jobs_pos = output.find("\njobs:").expect("no jobs: section");
1124 let workflows_pos = output.find("\nworkflows:").expect("no workflows: section");
1125 let pos = output
1126 .find(" build-binary-release:")
1127 .expect("no build-binary-release job");
1128 assert!(
1129 pos > jobs_pos && pos < workflows_pos,
1130 "build-binary-release must be in the jobs section:\n{output}"
1131 );
1132 }
1133
1134 #[test]
1135 fn build_binary_release_job_uses_rust_latest() {
1136 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1137 let block = job_block(&output, "build-binary-release");
1138 assert!(
1139 block.contains("rust:latest"),
1140 "build-binary-release must use the public rust:latest image:\n{block}"
1141 );
1142 }
1143
1144 #[test]
1145 fn build_binary_release_job_has_package_flag() {
1146 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1147 let block = job_block(&output, "build-binary-release");
1148 assert!(
1149 block.contains("cargo build --release -p mytool"),
1150 "build-binary-release must compile with -p <binary> flag:\n{block}"
1151 );
1152 }
1153
1154 #[test]
1155 fn build_binary_release_job_persists_binary_to_workspace() {
1156 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1157 let block = job_block(&output, "build-binary-release");
1158 assert!(
1159 block.contains("persist_to_workspace"),
1160 "build-binary-release must persist binary to workspace:\n{block}"
1161 );
1162 assert!(
1163 block.contains("paths: [mytool]"),
1164 "build-binary-release must persist the binary by name:\n{block}"
1165 );
1166 }
1167
1168 #[test]
1171 fn build_container_sources_versions_env() {
1172 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1173 let block = job_block(&output, "build-container");
1174 assert!(
1175 block.contains("source /tmp/release-versions/versions.env"),
1176 "build-container must source versions.env to get the release version:\n{block}"
1177 );
1178 }
1179
1180 #[test]
1181 fn build_container_attaches_release_versions_workspace() {
1182 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1183 let block = job_block(&output, "build-container");
1184 assert!(
1185 block.contains("at: /tmp/release-versions"),
1186 "build-container must attach the release-versions workspace:\n{block}"
1187 );
1188 }
1189
1190 #[test]
1191 fn build_container_attaches_bin_workspace() {
1192 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1193 let block = job_block(&output, "build-container");
1194 assert!(
1195 block.contains("at: /tmp/bin"),
1196 "build-container must attach the bin workspace to get the compiled binary:\n{block}"
1197 );
1198 }
1199
1200 #[test]
1201 fn build_container_copies_workspace_binary() {
1202 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1203 let block = job_block(&output, "build-container");
1204 assert!(
1205 block.contains("cp /tmp/bin/mytool orb/mytool"),
1206 "build-container must copy binary from workspace into the Docker build context:\n{block}"
1207 );
1208 }
1209
1210 #[test]
1211 fn build_container_uses_crate_version_var() {
1212 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1213 let block = job_block(&output, "build-container");
1214 assert!(
1217 block.contains("CRATE_VERSION_MYTOOL"),
1218 "build-container must derive version from CRATE_VERSION_<BINARY> env var:\n{block}"
1219 );
1220 }
1221
1222 #[test]
1225 fn release_workflow_has_build_binary_release_step() {
1226 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1227 assert!(
1228 output.contains(" - build-binary-release:"),
1229 "missing build-binary-release workflow step:\n{output}"
1230 );
1231 }
1232
1233 #[test]
1234 fn release_workflow_binary_and_pack_both_require_approval_gate() {
1235 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1236 let after_binary = output
1238 .split(" - build-binary-release:")
1239 .nth(1)
1240 .expect("no build-binary-release workflow step");
1241 let binary_step = after_binary
1242 .split(" - ")
1243 .next()
1244 .unwrap_or(after_binary);
1245 assert!(
1246 binary_step.contains("requires: [approve-release]"),
1247 "build-binary-release workflow step must require the approval gate:\n{binary_step}"
1248 );
1249 let after_pack = output
1251 .split("name: pack-orb-release")
1252 .nth(1)
1253 .expect("no pack-orb-release step");
1254 let pack_step = after_pack.split(" - ").next().unwrap_or(after_pack);
1255 assert!(
1256 pack_step.contains("requires: [approve-release]"),
1257 "pack-orb-release step must require the approval gate (runs in parallel with build-binary-release):\n{pack_step}"
1258 );
1259 }
1260
1261 #[test]
1262 fn release_workflow_container_requires_build_binary_release() {
1263 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1264 let after_container = output
1265 .split(" - build-container:")
1266 .nth(1)
1267 .expect("no build-container workflow step");
1268 let step_block = after_container
1269 .split(" - ")
1270 .next()
1271 .unwrap_or(after_container);
1272 assert!(
1273 step_block.contains("requires: [build-binary-release]"),
1274 "build-container must require build-binary-release (not the approval gate directly):\n{step_block}"
1275 );
1276 }
1277
1278 #[test]
1279 fn release_workflow_publish_has_pre_steps_circle_tag_injection() {
1280 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1281 assert!(
1285 output.contains("pre-steps:"),
1286 "orb-tools/publish must have pre-steps to inject CIRCLE_TAG:\n{output}"
1287 );
1288 assert!(
1289 output.contains("Export orb version as CIRCLE_TAG"),
1290 "pre-steps must contain a named step that exports CIRCLE_TAG:\n{output}"
1291 );
1292 assert!(
1293 output.contains("CIRCLE_TAG"),
1294 "pre-steps must set CIRCLE_TAG in BASH_ENV:\n{output}"
1295 );
1296 assert!(
1297 output.contains("BASH_ENV"),
1298 "pre-steps must export CIRCLE_TAG via BASH_ENV:\n{output}"
1299 );
1300 }
1301
1302 #[test]
1303 fn release_workflow_publish_pre_steps_use_versions_env() {
1304 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1305 assert!(
1308 output.contains("/tmp/release-versions"),
1309 "publish pre-steps must attach the release-versions workspace:\n{output}"
1310 );
1311 assert!(
1312 output.contains("source /tmp/release-versions/versions.env"),
1313 "publish pre-steps must source versions.env to read CRATE_VERSION_*:\n{output}"
1314 );
1315 }
1316
1317 #[test]
1318 fn release_workflow_publish_pre_steps_inject_correct_variable() {
1319 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1320 assert!(
1325 output.contains("CIRCLE_TAG=v${CRATE_VERSION_MYTOOL}"),
1326 "CIRCLE_TAG must have a v prefix — orb-tools/publish requires tag pattern ^v\\d+\\.\\d+\\.\\d+$:\n{output}"
1327 );
1328 }
1329
1330 #[test]
1337 fn patch_release_adds_ensure_orb_registered_job() {
1338 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1339 assert!(
1341 output.contains(" ensure-orb-registered-my-org:"),
1342 "missing ensure-orb-registered-my-org job definition:\n{output}"
1343 );
1344 let jobs_pos = output.find("\njobs:").expect("no jobs: section");
1345 let workflows_pos = output.find("\nworkflows:").expect("no workflows: section");
1346 let pos = output
1347 .find(" ensure-orb-registered-my-org:")
1348 .expect("no ensure-orb-registered-my-org job");
1349 assert!(
1350 pos > jobs_pos && pos < workflows_pos,
1351 "ensure-orb-registered-my-org must be defined in the jobs section:\n{output}"
1352 );
1353 }
1354
1355 #[test]
1356 fn ensure_orb_registered_job_uses_orb_tools_executor() {
1357 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1358 let block = job_block(&output, "ensure-orb-registered-my-org");
1359 assert!(
1362 block.contains("executor: orb-tools/default"),
1363 "ensure-orb-registered-my-org must use orb-tools/default executor (has CLI pre-installed):\n{block}"
1364 );
1365 }
1366
1367 #[test]
1368 fn ensure_orb_registered_job_runs_circleci_setup() {
1369 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1370 let block = job_block(&output, "ensure-orb-registered-my-org");
1371 assert!(
1374 block.contains("circleci setup") && block.contains("CIRCLE_TOKEN"),
1375 "ensure-orb-registered-my-org must run circleci setup --token ${{CIRCLE_TOKEN}}:\n{block}"
1376 );
1377 }
1378
1379 #[test]
1380 fn ensure_orb_registered_job_uses_idempotent_check() {
1381 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1382 let block = job_block(&output, "ensure-orb-registered-my-org");
1383 assert!(
1384 block.contains("circleci orb info my-org/mytool"),
1385 "ensure job must check the specific orb before creating:\n{block}"
1386 );
1387 assert!(
1388 block.contains("circleci orb create my-org/mytool --no-prompt"),
1389 "ensure job must create the specific orb with --no-prompt:\n{block}"
1390 );
1391 assert!(
1392 !block.contains("|| true"),
1393 "must not use `|| true` — real failures (wrong token, wrong namespace) must surface:\n{block}"
1394 );
1395 }
1396
1397 #[test]
1398 fn ensure_orb_registered_job_handles_already_exists_gracefully() {
1399 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1403 let block = job_block(&output, "ensure-orb-registered-my-org");
1404 assert!(
1405 block.contains("already exists"),
1406 "ensure job must handle 'already exists' error from circleci orb create:\n{block}"
1407 );
1408 assert!(
1409 !block.contains("|| \\"),
1410 "ensure job must not use single-line || fallback — use captured exit-code check instead:\n{block}"
1411 );
1412 }
1413
1414 #[test]
1415 fn release_workflow_has_ensure_orb_registered_step() {
1416 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1417 assert!(
1418 output.contains(" - ensure-orb-registered-my-org:"),
1419 "missing ensure-orb-registered-my-org workflow step:\n{output}"
1420 );
1421 }
1422
1423 #[test]
1424 fn release_workflow_ensure_orb_step_requires_approval_gate() {
1425 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1426 let after_step = output
1427 .split(" - ensure-orb-registered-my-org:")
1428 .nth(1)
1429 .expect("no ensure-orb-registered-my-org workflow step");
1430 let step_block = after_step.split(" - ").next().unwrap_or(after_step);
1431 assert!(
1432 step_block.contains("requires: [approve-release]"),
1433 "ensure-orb-registered-my-org must require the approval gate:\n{step_block}"
1434 );
1435 }
1436
1437 #[test]
1438 fn release_workflow_ensure_orb_step_uses_orb_publishing_context() {
1439 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1440 let after_step = output
1441 .split(" - ensure-orb-registered-my-org:")
1442 .nth(1)
1443 .expect("no ensure-orb-registered-my-org workflow step");
1444 let step_block = after_step.split(" - ").next().unwrap_or(after_step);
1445 assert!(
1446 step_block.contains("context: [orb-publishing]"),
1447 "ensure-orb-registered-my-org must run in the orb-publishing context:\n{step_block}"
1448 );
1449 }
1450
1451 #[test]
1452 fn release_workflow_publish_requires_ensure_orb_registered() {
1453 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1454 let after_publish = output
1455 .split(" - orb-tools/publish:")
1456 .nth(1)
1457 .expect("no orb-tools/publish step");
1458 let publish_block = after_publish
1461 .split("\n - ")
1462 .next()
1463 .unwrap_or(after_publish);
1464 assert!(
1465 publish_block.contains("ensure-orb-registered"),
1466 "orb-tools/publish must require ensure-orb-registered:\n{publish_block}"
1467 );
1468 }
1469
1470 #[test]
1471 fn release_workflow_publish_pre_steps_no_ensure_orb_step() {
1472 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1473 let after_publish = output
1476 .split(" - orb-tools/publish:")
1477 .nth(1)
1478 .expect("no orb-tools/publish step");
1479 let pre_steps_block = after_publish
1480 .split(" orb_name:")
1481 .next()
1482 .unwrap_or(after_publish);
1483 assert!(
1484 !pre_steps_block.contains("circleci orb info"),
1485 "orb info must not be in orb-tools/publish pre-steps — it belongs in the separate ensure-orb-registered job:\n{pre_steps_block}"
1486 );
1487 assert!(
1488 !pre_steps_block.contains("circleci orb create"),
1489 "orb create must not be in orb-tools/publish pre-steps — it belongs in the separate ensure-orb-registered job:\n{pre_steps_block}"
1490 );
1491 }
1492
1493 #[test]
1496 fn patch_release_does_not_rewire_toolkit_release_crate_requires() {
1497 let (output, report) = patch_release(RELEASE_FIXTURE_WITH_RELEASE_CRATE, &make_opts());
1503 let wf_section = output.split("workflows:").nth(1).unwrap_or("");
1504 let after_rc = wf_section
1505 .split("- toolkit/release_crate:")
1506 .nth(1)
1507 .expect("no toolkit/release_crate step");
1508 let block = after_rc.split("\n - ").next().unwrap_or(after_rc);
1509 assert!(
1510 block.contains("approve-release"),
1511 "toolkit/release_crate requires must remain [approve-release] — patcher must not rewire it:\n{block}"
1512 );
1513 assert!(
1514 !block.contains("publish-orb"),
1515 "patcher must not inject publish-orb into release_crate requires:\n{block}"
1516 );
1517 assert!(
1518 !report.insertions.iter().any(|s| s.contains("rewire")),
1519 "no rewire insertion should be reported:\n{:?}",
1520 report.insertions
1521 );
1522 }
1523
1524 #[test]
1530 fn patch_release_adds_per_namespace_ensure_and_publish_jobs() {
1531 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts_two_ns());
1532 assert!(
1533 output.contains(" ensure-orb-registered-my-org:"),
1534 "missing ensure-orb-registered-my-org job:\n{output}"
1535 );
1536 assert!(
1537 output.contains(" ensure-orb-registered-other-org:"),
1538 "missing ensure-orb-registered-other-org job:\n{output}"
1539 );
1540 assert!(
1541 output.contains("name: publish-orb-my-org"),
1542 "missing publish-orb-my-org step:\n{output}"
1543 );
1544 assert!(
1545 output.contains("name: publish-orb-other-org"),
1546 "missing publish-orb-other-org step:\n{output}"
1547 );
1548 }
1549
1550 #[test]
1551 fn each_publish_job_requires_its_own_ensure_job() {
1552 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts_two_ns());
1553 let after_my_org = output
1557 .split("name: publish-orb-my-org")
1558 .nth(1)
1559 .expect("no publish-orb-my-org step");
1560 let my_org_block = after_my_org
1561 .split("\n - ")
1562 .next()
1563 .unwrap_or(after_my_org);
1564 assert!(
1565 my_org_block.contains("ensure-orb-registered-my-org"),
1566 "publish-orb-my-org must require ensure-orb-registered-my-org:\n{my_org_block}"
1567 );
1568 assert!(
1569 !my_org_block.contains("ensure-orb-registered-other-org"),
1570 "publish-orb-my-org must NOT require other-org's ensure job:\n{my_org_block}"
1571 );
1572
1573 let after_other_org = output
1575 .split("name: publish-orb-other-org")
1576 .nth(1)
1577 .expect("no publish-orb-other-org step");
1578 let other_org_block = after_other_org
1579 .split("\n - ")
1580 .next()
1581 .unwrap_or(after_other_org);
1582 assert!(
1583 other_org_block.contains("ensure-orb-registered-other-org"),
1584 "publish-orb-other-org must require ensure-orb-registered-other-org:\n{other_org_block}"
1585 );
1586 }
1587
1588 #[test]
1589 fn multi_namespace_patch_release_is_idempotent() {
1590 let (first, _) = patch_release(RELEASE_FIXTURE, &make_opts_two_ns());
1591 let (second, second_report) = patch_release(&first, &make_opts_two_ns());
1592 assert_eq!(
1593 first, second,
1594 "second multi-namespace patch must not change content"
1595 );
1596 assert!(
1597 !second_report.skipped.is_empty(),
1598 "expected skipped entries on second run"
1599 );
1600 }
1601
1602 #[test]
1609 fn ensure_orb_create_omits_private_flag_for_public_orb() {
1610 let (output, _) = patch_release(RELEASE_FIXTURE, &make_opts());
1611 let block = job_block(&output, "ensure-orb-registered-my-org");
1612 assert!(
1613 block.contains("circleci orb create my-org/mytool --no-prompt"),
1614 "public orb create must not have --private flag:\n{block}"
1615 );
1616 assert!(
1617 !block.contains("--private"),
1618 "public orb must not pass --private to circleci orb create:\n{block}"
1619 );
1620 }
1621
1622 #[test]
1623 fn ensure_orb_create_includes_private_flag_when_namespace_is_private() {
1624 let opts = PatchOpts {
1625 private_namespaces: vec!["my-org".to_string()],
1626 ..make_opts()
1627 };
1628 let (output, _) = patch_release(RELEASE_FIXTURE, &opts);
1629 let block = job_block(&output, "ensure-orb-registered-my-org");
1630 assert!(
1631 block.contains("circleci orb create my-org/mytool --private --no-prompt"),
1632 "private namespace orb create must include --private flag:\n{block}"
1633 );
1634 }
1635
1636 #[test]
1637 fn mixed_visibility_public_namespace_has_no_private_flag() {
1638 let opts = PatchOpts {
1640 namespaces: vec!["my-org".to_string(), "other-org".to_string()],
1641 private_namespaces: vec!["other-org".to_string()],
1642 ..make_opts()
1643 };
1644 let (output, _) = patch_release(RELEASE_FIXTURE, &opts);
1645 let my_org_block = job_block(&output, "ensure-orb-registered-my-org");
1646 assert!(
1647 !my_org_block.contains("--private"),
1648 "my-org is public — must not have --private:\n{my_org_block}"
1649 );
1650 assert!(
1651 my_org_block.contains("circleci orb create my-org/mytool --no-prompt"),
1652 "my-org create must be public:\n{my_org_block}"
1653 );
1654 }
1655
1656 #[test]
1657 fn mixed_visibility_private_namespace_has_private_flag() {
1658 let opts = PatchOpts {
1660 namespaces: vec!["my-org".to_string(), "other-org".to_string()],
1661 private_namespaces: vec!["other-org".to_string()],
1662 ..make_opts()
1663 };
1664 let (output, _) = patch_release(RELEASE_FIXTURE, &opts);
1665 let other_org_block = job_block(&output, "ensure-orb-registered-other-org");
1666 assert!(
1667 other_org_block.contains("circleci orb create other-org/mytool --private --no-prompt"),
1668 "other-org is private — create must have --private:\n{other_org_block}"
1669 );
1670 }
1671
1672 #[test]
1673 fn release_workflow_steps_always_requires_release_after_job() {
1674 let opts = make_opts(); let (output, _) = patch_release(RELEASE_FIXTURE, &opts);
1678 let wf = output.split("workflows:").nth(1).unwrap_or("");
1679 let after_build = wf
1680 .split("- build-binary-release:")
1681 .nth(1)
1682 .expect("no build-binary-release step");
1683 let block = after_build
1684 .split("\n - ")
1685 .next()
1686 .unwrap_or(after_build);
1687 assert!(
1688 block.contains("requires: [approve-release]"),
1689 "build-binary-release must have requires: [approve-release]:\n{block}"
1690 );
1691 }
1692}