ossctl_core/release/adapters/homebrew.rs
1//! Homebrew distribution adapter: `homebrew-tap` and `homebrew-core`.
2//!
3//! Updates a Homebrew formula (a custom tap, or a `homebrew-core` bump PR) so
4//! `brew install` resolves the new version. A tap/core is not observable through
5//! the [`RegistryQuery`](crate::ports::RegistryQuery) port, so `verify` returns
6//! [`VerifyOutcome::Unknown`] **explicitly** rather than being excused from the
7//! contract (ADR-0002 §1) — an honest "cannot check", never a false `Missing`.
8//!
9//! ## Create vs. bump (the first-formula bootstrap)
10//!
11//! `brew bump-formula-pr` *updates* a formula that already exists — on a fresh,
12//! empty tap there is nothing to bump, so the very first release must **create**
13//! the initial `<name>.rb` instead. This adapter chooses between the two paths by
14//! asking the tap whether the formula already exists (through the injected
15//! [`CommandRunner`](crate::ports::CommandRunner), so it is testable with no real
16//! network or tap):
17//!
18//! - **absent** → the *create* path: generate a source-build formula (the release
19//! tarball's `url` + `sha256`, the license, a cargo build/install stanza), clone
20//! the tap, commit the new file on a branch, and open a PR.
21//! - **present** → the *bump* path: `brew bump-formula-pr` carrying the release
22//! tarball's `--url` (+ `--sha256` when a digest is available).
23//!
24//! The create path only applies to a `homebrew-tap` target whose destination tap
25//! the contract configured (`ctx.artifacts.homebrew.tap`). A `homebrew-core`
26//! target — or a `homebrew-tap` with no resolved tap — falls back to the plain
27//! bump path (first submission to `homebrew-core` is a human review process, not
28//! an automated create).
29
30use std::path::PathBuf;
31use std::time::Duration;
32
33use crate::contract::schema::Adapter;
34use crate::protocol::release::{
35 BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt, VerifyOutcome,
36};
37
38use super::{
39 make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, HomebrewFormula, ReleaseAdapter,
40 SourceTarball,
41};
42
43/// The homebrew distribution adapter, operating as `homebrew-tap` or
44/// `homebrew-core`.
45pub struct HomebrewAdapter {
46 adapter: Adapter,
47}
48
49/// Which formula operation the adapter resolved for a target — the create path
50/// (a first formula on an empty tap) or the bump path (an existing formula).
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52enum FormulaPath {
53 /// Generate + PR the initial `<name>.rb` (the tap has no formula yet).
54 Create,
55 /// `brew bump-formula-pr` an already-present formula.
56 Bump,
57}
58
59impl HomebrewAdapter {
60 /// Construct for a resolved homebrew adapter identity.
61 #[must_use]
62 pub fn new(adapter: Adapter) -> Self {
63 debug_assert!(matches!(
64 adapter,
65 Adapter::HomebrewTap | Adapter::HomebrewCore
66 ));
67 Self { adapter }
68 }
69
70 /// The destination tap slug (`owner/repo`) for a `homebrew-tap` create, when
71 /// the contract configured one. `None` for `homebrew-core` or an unconfigured
72 /// tap — which pins the adapter to the bump path (no bootstrap destination).
73 fn tap<'a>(&self, artifacts: Option<&'a HomebrewFormula>) -> Option<&'a str> {
74 if self.adapter != Adapter::HomebrewTap {
75 return None;
76 }
77 artifacts.and_then(|h| h.tap.as_deref())
78 }
79
80 /// Decide the create-vs-bump path for `target`: a `homebrew-tap` with a
81 /// configured tap probes the tap for the formula (create when absent); every
82 /// other case is a bump.
83 ///
84 /// The probe runs through the injected runner (`gh api …/contents/…`); a
85 /// non-zero exit (a `404`, typically) reads as *absent* → create. A spawn
86 /// failure is a real error and is propagated.
87 fn resolve_path(
88 &self,
89 ctx: &EffectCtx<'_>,
90 t: &AdapterTarget,
91 ) -> Result<FormulaPath, AdapterError> {
92 let homebrew = ctx.artifacts.homebrew.as_ref();
93 match self.tap(homebrew) {
94 Some(tap) if !Self::formula_exists(ctx, tap, &t.package)? => Ok(FormulaPath::Create),
95 _ => Ok(FormulaPath::Bump),
96 }
97 }
98
99 /// Ask the tap whether `Formula/<name>.rb` already exists, through the runner.
100 ///
101 /// Uses `gh api` against the tap's contents endpoint so no local checkout is
102 /// needed for the probe. Only three outcomes are safe to act on:
103 ///
104 /// - exit `0` (the file is served) ⇒ **present** → bump.
105 /// - a genuine `404` (`gh` renders it as `Not Found (HTTP 404)`) ⇒ **absent**
106 /// → create.
107 /// - **anything else** — auth failure, rate-limit, network error, a private or
108 /// renamed tap, a 5xx — is an [`AdapterError::Command`], **not** "absent".
109 /// Treating an infrastructure error as absence would trigger a spurious
110 /// create that clones the tap and could overwrite an existing formula.
111 ///
112 /// A spawn failure (the port could not run `gh`) is a genuine
113 /// [`AdapterError::Io`].
114 fn formula_exists(ctx: &EffectCtx<'_>, tap: &str, name: &str) -> Result<bool, AdapterError> {
115 let endpoint = format!("repos/{tap}/contents/Formula/{name}.rb");
116 let cmd = PlannedCommand::new("gh", &["api", "--silent", &endpoint]);
117 let out = ctx
118 .runner
119 .run("gh", &["api", "--silent", &endpoint], ctx.repo_root)
120 .map_err(|e| AdapterError::Io {
121 command: cmd.rendered(),
122 source: e.to_string(),
123 })?;
124 if out.status == Some(0) {
125 return Ok(true);
126 }
127 // A 404 is the only non-zero exit that means "absent". `gh` prints
128 // `Not Found (HTTP 404)`; match the stable `404` token on either stream.
129 if out.stderr.contains("404") || out.stdout.contains("404") {
130 return Ok(false);
131 }
132 let detail = if out.stderr.trim().is_empty() {
133 out.stdout
134 } else {
135 out.stderr
136 };
137 Err(AdapterError::Command {
138 command: cmd.rendered(),
139 code: out.status,
140 stderr: detail,
141 })
142 }
143
144 /// The `brew bump-formula-pr` command for an existing formula, carrying the
145 /// threaded release tarball `--url` (+ `--sha256` when a digest is present).
146 /// Options precede the `--` terminator so a formula name is never parsed as a
147 /// flag. Unchanged from the pre-bootstrap behaviour.
148 fn bump_command(&self, tarball: Option<&SourceTarball>, name: &str) -> PlannedCommand {
149 let mut args: Vec<String> = match self.adapter {
150 Adapter::HomebrewCore => vec!["bump-formula-pr".into(), "--no-fork".into()],
151 _ => vec!["bump-formula-pr".into()],
152 };
153 if let Some(tarball) = tarball {
154 args.push("--url".into());
155 args.push(tarball.url.clone());
156 if let Some(sha256) = &tarball.sha256 {
157 args.push("--sha256".into());
158 args.push(sha256.clone());
159 }
160 }
161 args.push("--".into());
162 args.push(name.to_string());
163 PlannedCommand {
164 program: "brew".into(),
165 args,
166 }
167 }
168
169 /// A **fresh, unpredictable** scratch checkout the create path clones the tap
170 /// into. Unique per attempt (pid + a monotonic-ish nanosecond stamp) so:
171 /// concurrent cuts/tests never collide; a retry never trips over a prior
172 /// attempt's leftover dir (the old "deterministic" path made `gh repo clone`
173 /// fail into a non-empty dir); and the unpredictable name defeats the classic
174 /// world-writable-`/tmp` symlink pre-creation (TOCTOU) attack. The file write
175 /// additionally uses create-new semantics (see [`Self::write_formula`]).
176 fn fresh_workdir(name: &str, version: &str) -> PathBuf {
177 let nanos = std::time::SystemTime::now()
178 .duration_since(std::time::UNIX_EPOCH)
179 .map_or(0, |d| d.as_nanos());
180 std::env::temp_dir().join(format!(
181 "ossctl-homebrew-{name}-{version}-{}-{nanos}",
182 std::process::id()
183 ))
184 }
185
186 /// The branch the create path commits the new formula on.
187 fn create_branch(name: &str, version: &str) -> String {
188 format!("ossctl-homebrew-{name}-{version}")
189 }
190
191 /// The commit/PR title for a first formula.
192 fn create_title(name: &str, version: &str) -> String {
193 format!("{name} {version} (new formula)")
194 }
195
196 /// The ordered git/`gh` commands the create path runs (clone → branch → add →
197 /// commit → push → PR), into the pre-computed `workdir`. The generated `.rb`
198 /// is written to disk *between* the clone and the `add` (see
199 /// [`Self::publish`]); these are only the process steps, shared by
200 /// [`Self::dry_run`]'s preview and [`Self::publish`].
201 ///
202 /// `sha256_present` gates two things: a **draft** PR and a blocker in the body.
203 /// When the source-tarball digest is not yet known (the coordinator threads
204 /// `sha256: None` pre-tag), the generated formula carries only a `sha256`
205 /// TODO and would fail `brew audit` / cannot install — so the PR is opened as a
206 /// draft whose body states the one remaining manual step, rather than a
207 /// mergeable-looking PR that is silently broken.
208 fn create_commands(
209 tap: &str,
210 name: &str,
211 version: &str,
212 workdir: &str,
213 sha256_present: bool,
214 ) -> Vec<PlannedCommand> {
215 let branch = Self::create_branch(name, version);
216 let title = Self::create_title(name, version);
217 let formula_rel = format!("Formula/{name}.rb");
218 let body = if sha256_present {
219 "Automated first-formula bootstrap by ossctl.".to_string()
220 } else {
221 "Automated first-formula bootstrap by ossctl.\n\n**Blocked:** the \
222 `sha256` of the published release tarball is not yet known at cut \
223 time (the tag archive does not exist until after publish). Fill in \
224 the `sha256` once the tag is pushed, then mark this PR ready."
225 .to_string()
226 };
227 let mut pr = vec![
228 "pr".to_string(),
229 "create".to_string(),
230 "--repo".to_string(),
231 tap.to_string(),
232 "--head".to_string(),
233 branch.clone(),
234 "--title".to_string(),
235 title.clone(),
236 "--body".to_string(),
237 body,
238 ];
239 if !sha256_present {
240 pr.push("--draft".to_string());
241 }
242 vec![
243 PlannedCommand::new("gh", &["repo", "clone", tap, workdir, "--", "--depth", "1"]),
244 PlannedCommand::new("git", &["-C", workdir, "checkout", "-b", &branch]),
245 PlannedCommand::new("git", &["-C", workdir, "add", &formula_rel]),
246 // Set the commit identity explicitly (via `-c`): the freshly-cloned tap
247 // inherits no `user.name`/`user.email`, so on a clean CI runner an
248 // identity-less `git commit` fails with "Author identity unknown".
249 PlannedCommand::new(
250 "git",
251 &[
252 "-C",
253 workdir,
254 "-c",
255 "user.name=ossctl",
256 "-c",
257 "user.email=ossctl@users.noreply.github.com",
258 "commit",
259 "-m",
260 &title,
261 ],
262 ),
263 PlannedCommand::new(
264 "git",
265 &["-C", workdir, "push", "--set-upstream", "origin", &branch],
266 ),
267 PlannedCommand {
268 program: "gh".to_string(),
269 args: pr,
270 },
271 ]
272 }
273
274 /// Run the create path: generate the initial formula, clone the tap, write the
275 /// file, commit it on a branch, and open a PR — all effects through the runner
276 /// except the single filesystem write of the generated `.rb`.
277 fn run_create(
278 ctx: &EffectCtx<'_>,
279 t: &AdapterTarget,
280 tap: &str,
281 ) -> Result<PublishReceipt, AdapterError> {
282 let tarball =
283 ctx.artifacts
284 .source_tarball
285 .as_ref()
286 .ok_or_else(|| AdapterError::Command {
287 command: "homebrew first-formula".into(),
288 code: None,
289 stderr: "cannot generate a first Homebrew formula without a resolvable GitHub \
290 source-tarball URL (no `origin` GitHub remote?)"
291 .into(),
292 })?;
293 let license = ctx
294 .artifacts
295 .homebrew
296 .as_ref()
297 .and_then(|h| h.license.as_deref());
298 let homepage_slug = ctx.artifacts.repo_slug.as_deref();
299 let formula = render_formula(
300 &t.package,
301 homepage_slug,
302 &tarball.url,
303 tarball.sha256.as_deref(),
304 license,
305 );
306
307 // One workdir, computed once, used by both the clone and the write.
308 let workdir = Self::fresh_workdir(&t.package, &t.version);
309 let workdir_str = workdir.to_string_lossy().to_string();
310 let commands = Self::create_commands(
311 tap,
312 &t.package,
313 &t.version,
314 &workdir_str,
315 tarball.sha256.is_some(),
316 );
317 // 1. clone the tap.
318 run_all(ctx, &commands[..1])?;
319 // 2. write the generated formula into the checkout (create-new: refuses to
320 // overwrite a formula that already exists in the clone — the last-line
321 // guard against a probe/clone race or a mis-detected "absent").
322 Self::write_formula(&workdir, &t.package, &formula)?;
323 // 3. branch → add → commit → push → PR.
324 let outputs = run_all(ctx, &commands[1..])?;
325
326 // Record the PR URL `gh pr create` prints as the receipt's `remote_url`
327 // (the field already existed — recording it is not a JSON-shape change).
328 // `gh` can precede the URL with status lines, so take the last line that
329 // looks like a URL rather than the whole stdout blob.
330 let remote_url = outputs.last().and_then(|o| {
331 o.stdout
332 .lines()
333 .rev()
334 .map(str::trim)
335 .find(|line| line.starts_with("https://"))
336 .map(str::to_string)
337 });
338 Ok(make_receipt(ctx, t, None, remote_url))
339 }
340
341 /// Write the generated formula to `<workdir>/Formula/<name>.rb`, creating the
342 /// `Formula/` directory if the freshly-cloned tap does not carry it yet.
343 ///
344 /// This is the one direct-filesystem effect in the adapter — Homebrew has no
345 /// "add a formula" CLI; a new formula *is* a committed file, so `run_all`
346 /// (which only *runs processes*) cannot express it. It is deliberately scoped:
347 /// it writes exactly one file into a private, unpredictable [`Self::fresh_workdir`]
348 /// the create path just cloned into, and it uses **create-new** semantics
349 /// (`O_EXCL`) so it never follows a symlink onto, or truncates, an existing
350 /// file — which also fails loudly if the tap already carries the formula (a
351 /// last-line guard against a mis-detected "absent" formula). A general
352 /// filesystem port on `EffectCtx` is the cleaner long-term home (issue
353 /// `homebrew-adapter-fs-port`); until then this is mapped to a distinct
354 /// [`AdapterError::Filesystem`] so the effect is explicit, not hidden.
355 fn write_formula(
356 workdir: &std::path::Path,
357 name: &str,
358 formula: &str,
359 ) -> Result<(), AdapterError> {
360 let dir = workdir.join("Formula");
361 std::fs::create_dir_all(&dir).map_err(|e| AdapterError::Filesystem {
362 path: dir.to_string_lossy().to_string(),
363 source: e.to_string(),
364 })?;
365 let path = dir.join(format!("{name}.rb"));
366 let mut file = std::fs::OpenOptions::new()
367 .write(true)
368 .create_new(true)
369 .open(&path)
370 .map_err(|e| AdapterError::Filesystem {
371 path: path.to_string_lossy().to_string(),
372 source: e.to_string(),
373 })?;
374 std::io::Write::write_all(&mut file, formula.as_bytes()).map_err(|e| {
375 AdapterError::Filesystem {
376 path: path.to_string_lossy().to_string(),
377 source: e.to_string(),
378 }
379 })
380 }
381}
382
383impl ReleaseAdapter for HomebrewAdapter {
384 fn adapter(&self) -> Adapter {
385 self.adapter
386 }
387
388 fn dry_run(
389 &self,
390 ctx: &EffectCtx<'_>,
391 t: &AdapterTarget,
392 ) -> Result<DryRunReport, AdapterError> {
393 let tarball = ctx.artifacts.source_tarball.as_ref();
394 let (planned_commands, mut notes) = match self.resolve_path(ctx, t)? {
395 FormulaPath::Create => {
396 // `tap` is Some whenever resolve_path returned Create.
397 let tap = self
398 .tap(ctx.artifacts.homebrew.as_ref())
399 .unwrap_or_default();
400 let workdir = Self::fresh_workdir(&t.package, &t.version);
401 let sha256_present = tarball.and_then(|tb| tb.sha256.as_deref()).is_some();
402 (
403 Self::create_commands(
404 tap,
405 &t.package,
406 &t.version,
407 &workdir.to_string_lossy(),
408 sha256_present,
409 ),
410 vec![format!(
411 "create path: `{}` has no `{}.rb` yet — generating the initial \
412 source-build formula and opening a{} PR",
413 tap,
414 t.package,
415 if sha256_present { "" } else { " draft" }
416 )],
417 )
418 }
419 FormulaPath::Bump => (
420 vec![self.bump_command(tarball, &t.package)],
421 vec!["bump path: the formula already exists — bumping its url/sha256".to_string()],
422 ),
423 };
424 match tarball {
425 Some(tb) => {
426 let sha = tb
427 .sha256
428 .as_deref()
429 .unwrap_or("(computed by brew from --url)");
430 notes.push(format!("url: {} ; sha256: {sha}", tb.url));
431 }
432 None => notes
433 .push("source tarball url is resolved by the coordinator at cut time".to_string()),
434 }
435 Ok(DryRunReport {
436 adapter: self.adapter,
437 planned_commands,
438 notes,
439 })
440 }
441
442 fn build(
443 &self,
444 _ctx: &EffectCtx<'_>,
445 _t: &AdapterTarget,
446 ) -> Result<BuildArtifacts, AdapterError> {
447 // Homebrew has no build phase of its own — it repackages an existing
448 // release artifact. Return an empty manifest rather than shelling out.
449 Ok(BuildArtifacts {
450 adapter: self.adapter,
451 artifacts: vec![],
452 notes: vec!["homebrew has no build phase (formula create/update only)".to_string()],
453 })
454 }
455
456 fn publish(
457 &self,
458 ctx: &EffectCtx<'_>,
459 t: &AdapterTarget,
460 ) -> Result<PublishReceipt, AdapterError> {
461 // PER-TARGET IRREVERSIBLE (opens a formula create/bump PR).
462 match self.resolve_path(ctx, t)? {
463 FormulaPath::Create => {
464 let tap = self
465 .tap(ctx.artifacts.homebrew.as_ref())
466 .expect("resolve_path returns Create only when a tap is configured");
467 Self::run_create(ctx, t, tap)
468 }
469 FormulaPath::Bump => {
470 let cmd = self.bump_command(ctx.artifacts.source_tarball.as_ref(), &t.package);
471 run_all(ctx, &[cmd])?;
472 Ok(make_receipt(ctx, t, None, None))
473 }
474 }
475 }
476
477 fn verify(
478 &self,
479 _ctx: &EffectCtx<'_>,
480 _receipt: &PublishReceipt,
481 ) -> Result<VerifyOutcome, AdapterError> {
482 // A tap/core formula is not observable through RegistryQuery; report the
483 // honest "cannot check" rather than a false Missing (ADR-0002 §1).
484 Ok(VerifyOutcome::Unknown)
485 }
486
487 fn timeout(&self) -> Duration {
488 Duration::from_secs(600)
489 }
490}
491
492/// Render a source-build Homebrew formula for `name` at `url`.
493///
494/// Produces the same shape as ossctl's own hand-written 0.1.0 formula: a cargo
495/// source build (`depends_on "rust" => :build` + `cargo install`). The install
496/// stanza is deliberately Rust-specific — the two consumers (`ossctl`,
497/// `issuectl`) are cargo CLIs, and the issue this implements reproduces that
498/// formula; a non-Rust source build is a documented follow-up.
499///
500/// `sha256`/`license` are optional: an absent `sha256` (the coordinator cannot
501/// hash the pushed tag archive before it exists — see the coordinator's
502/// `source_tarball` docs) emits a `TODO` placeholder the maintainer completes,
503/// mirroring the 0.1.0 hand-fill; an absent `license` omits the stanza.
504fn render_formula(
505 name: &str,
506 homepage_slug: Option<&str>,
507 url: &str,
508 sha256: Option<&str>,
509 license: Option<&str>,
510) -> String {
511 let class = formula_class(name);
512 // Every value interpolated into a Ruby double-quoted literal is escaped, so a
513 // `"` / `\` in a contract-supplied value cannot break out of the string (or
514 // inject Ruby). `name` reaches only `desc` and the `bin/"…"` test — the class
515 // name is already alphanumeric-only.
516 let name_lit = ruby_escape(name);
517 let homepage = homepage_slug.map_or_else(
518 || ruby_escape(url),
519 |s| ruby_escape(&format!("https://github.com/{s}")),
520 );
521 let url_lit = ruby_escape(url);
522 let sha_line = match sha256 {
523 Some(sha) => format!(" sha256 \"{}\"", ruby_escape(sha)),
524 None => " # TODO: sha256 of the published release tarball \
525 (unavailable at cut time — fill in after the tag archive exists)"
526 .to_string(),
527 };
528 let license_line = license
529 .map(|l| format!(" license \"{}\"\n", ruby_escape(l)))
530 .unwrap_or_default();
531 format!(
532 "class {class} < Formula\n\
533 \x20 desc \"{name_lit}\"\n\
534 \x20 homepage \"{homepage}\"\n\
535 \x20 url \"{url_lit}\"\n\
536 {sha_line}\n\
537 {license_line}\
538 \n\
539 \x20 depends_on \"rust\" => :build\n\
540 \n\
541 \x20 def install\n\
542 \x20 system \"cargo\", \"install\", *std_cargo_args\n\
543 \x20 end\n\
544 \n\
545 \x20 test do\n\
546 \x20 system bin/\"{name_lit}\", \"--version\"\n\
547 \x20 end\n\
548 end\n"
549 )
550}
551
552/// Escape a value for inclusion in a Ruby double-quoted string literal:
553/// backslashes first, then double quotes. Prevents a contract-supplied `"` or
554/// `\` from terminating the literal or injecting Ruby.
555fn ruby_escape(s: &str) -> String {
556 s.replace('\\', "\\\\").replace('"', "\\\"")
557}
558
559/// Homebrew's formula class name for `name`: alphanumeric runs capitalised and
560/// concatenated (`my-tool` → `MyTool`, `ossctl` → `Ossctl`). A small, faithful
561/// subset of Homebrew's `Formulary.class_s` — enough for the ordinary tap names
562/// this generator targets.
563///
564/// A Ruby constant may not begin with a digit, so a leading-digit name is
565/// prefixed with `X` (as Homebrew itself does: `2fa` → `X2fa`); a name that
566/// reduces to nothing falls back to `Formula` so the output is always a legal
567/// constant rather than a syntax error.
568fn formula_class(name: &str) -> String {
569 let mut out = String::new();
570 for segment in name.split(|c: char| !c.is_ascii_alphanumeric()) {
571 let mut chars = segment.chars();
572 if let Some(first) = chars.next() {
573 out.extend(first.to_uppercase());
574 out.push_str(chars.as_str());
575 }
576 }
577 if out.is_empty() {
578 return "Formula".to_string();
579 }
580 if out.starts_with(|c: char| c.is_ascii_digit()) {
581 out.insert(0, 'X');
582 }
583 out
584}