Skip to main content

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}