diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml index 75011765f..5154f27bc 100644 --- a/.github/workflows/release-pipeline.yml +++ b/.github/workflows/release-pipeline.yml @@ -1281,3 +1281,44 @@ jobs: notes.md retention-days: 7 if-no-files-found: error + + # After a stable release, publish its Homebrew formula to the + # quarto-dev/homebrew-q2 tap (`brew install quarto-dev/q2/q2`). + # HOMEBREW_TAP_TOKEN is a fine-grained token with contents:write on the + # tap only; until it is set, the job skips itself. + homebrew-tap: + name: Update Homebrew tap + needs: [release] + if: inputs.publish && inputs.channel == 'release' && !contains(inputs.version, '-') + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + + - name: Render formula and push to the tap + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + VERSION: ${{ inputs.version }} + run: | + if [ -z "$TAP_TOKEN" ]; then + echo "::notice::HOMEBREW_TAP_TOKEN is not set; skipping the Homebrew tap." + exit 0 + fi + gh release download "$TAG" -p checksums.sha256 -D "$RUNNER_TEMP" + scripts/homebrew-formula.sh "$VERSION" "$RUNNER_TEMP/checksums.sha256" > "$RUNNER_TEMP/q2.rb" + ruby -c "$RUNNER_TEMP/q2.rb" + git clone "https://x-access-token:${TAP_TOKEN}@github.com/quarto-dev/homebrew-q2.git" "$RUNNER_TEMP/tap" + cd "$RUNNER_TEMP/tap" + mkdir -p Formula + cp "$RUNNER_TEMP/q2.rb" Formula/q2.rb + git add Formula/q2.rb + git diff --cached --quiet && exit 0 + git -c user.name='github-actions[bot]' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit -m "q2 $VERSION" + git push diff --git a/claude-notes/instructions/release-runbook.md b/claude-notes/instructions/release-runbook.md index 312e8d9a7..2cc7f4fa5 100644 --- a/claude-notes/instructions/release-runbook.md +++ b/claude-notes/instructions/release-runbook.md @@ -77,6 +77,7 @@ These live in repo settings and rarely change. Verify with | `QUARTO_HUB_MCP_CLIENT_ID` | secret | Bundled Google OAuth client id (public client; secret only to dodge `GOCSPX-` scanners). | | `QUARTO_HUB_MCP_CLIENT_SECRET` | secret | Bundled client secret (same). | | `QUARTO_HUB_SERVER` | variable | `wss://quarto-hub.com/ws`. | +| `HOMEBREW_TAP_TOKEN` | secret | Fine-grained token, Contents read/write on `quarto-dev/homebrew-q2` only. The `homebrew-tap` job pushes the formula with it; unset, the job skips. | The signing **secret key** lives only in the repo secret and the team password manager. If it is ever rotated: regenerate with diff --git a/crates/quarto/tests/integration/homebrew_formula.rs b/crates/quarto/tests/integration/homebrew_formula.rs new file mode 100644 index 000000000..4a95ab76f --- /dev/null +++ b/crates/quarto/tests/integration/homebrew_formula.rs @@ -0,0 +1,110 @@ +//! Tests for scripts/homebrew-formula.sh, which renders the Homebrew +//! formula for a release from its `checksums.sha256`. +//! +//! Unix-only like nightly_gate.rs: the script is bash. + +#![cfg(unix)] + +use std::fs; +use std::path::Path; +use std::process::{Command, Output}; + +const PLATFORMS: [&str; 4] = ["darwin_arm64", "darwin_amd64", "linux_arm64", "linux_amd64"]; + +/// A checksums file like the release job's, with a distinct sha per +/// archive (the platform's index repeated) plus a Windows zip. +fn checksums(version: &str, skip: Option<&str>) -> String { + let mut out = String::new(); + for (i, p) in PLATFORMS.iter().enumerate() { + if Some(*p) != skip { + out += &format!( + "{} q2-{version}-{p}.tar.gz\n", + (i + 1).to_string().repeat(64) + ); + } + } + out + &format!("{} q2-{version}-windows_amd64.zip\n", "9".repeat(64)) +} + +fn render(version: &str, checksums: &str) -> Output { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("checksums.sha256"); + fs::write(&file, checksums).unwrap(); + Command::new("bash") + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/homebrew-formula.sh")) + .arg(version) + .arg(&file) + .output() + .unwrap() +} + +fn assert_fails_mentioning(out: &Output, needle: &str) { + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "expected failure"); + assert!(stderr.contains(needle), "stderr lacks {needle:?}: {stderr}"); + assert!(out.stdout.is_empty(), "printed a formula on failure"); +} + +#[test] +fn renders_each_platform_url_with_its_sha() { + let out = render("1.2.3", &checksums("1.2.3", None)); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let formula = String::from_utf8(out.stdout).unwrap(); + assert!(formula.contains("version \"1.2.3\""), "{formula}"); + + let mut last = formula.find("on_macos do").unwrap(); + for (i, p) in PLATFORMS.iter().enumerate() { + let url = format!( + "url \"https://github.com/quarto-dev/q2/releases/download/v1.2.3/q2-1.2.3-{p}.tar.gz\"" + ); + let at = formula + .find(&url) + .unwrap_or_else(|| panic!("no {url}\n{formula}")); + // Platforms appear in on_macos/on_linux, on_arm/on_intel order. + assert!(at > last, "{p} out of order\n{formula}"); + last = at; + let sha = formula[at..].lines().nth(1).unwrap().trim(); + assert_eq!( + sha, + format!("sha256 \"{}\"", (i + 1).to_string().repeat(64)) + ); + } + assert!(formula.find("on_linux do").unwrap() > formula.find("darwin_amd64").unwrap()); + assert!(!formula.contains("windows"), "{formula}"); + + // Ruby syntax check where ruby is available (it is on CI runners). + if Command::new("ruby").arg("--version").output().is_ok() { + let tmp = tempfile::tempdir().unwrap(); + let rb = tmp.path().join("q2.rb"); + fs::write(&rb, &formula).unwrap(); + let check = Command::new("ruby").arg("-c").arg(&rb).output().unwrap(); + assert!( + check.status.success(), + "{}", + String::from_utf8_lossy(&check.stderr) + ); + } +} + +#[test] +fn missing_archive_fails() { + let out = render("1.2.3", &checksums("1.2.3", Some("linux_arm64"))); + assert_fails_mentioning(&out, "q2-1.2.3-linux_arm64.tar.gz"); +} + +#[test] +fn another_versions_archives_do_not_match() { + let out = render("1.2.3", &checksums("1.2.30", None)); + assert_fails_mentioning(&out, "q2-1.2.3-darwin_arm64.tar.gz"); +} + +#[test] +fn only_bare_stable_versions_are_accepted() { + for bad in ["0.43.0-nightly.20261107", "v1.2.3"] { + assert_fails_mentioning(&render(bad, &checksums(bad, None)), "version"); + } +} diff --git a/crates/quarto/tests/integration/main.rs b/crates/quarto/tests/integration/main.rs index 792dd31e4..2cc3bb0ca 100644 --- a/crates/quarto/tests/integration/main.rs +++ b/crates/quarto/tests/integration/main.rs @@ -23,6 +23,7 @@ pub mod extension_config_spans; pub mod format_resolution_cli; pub mod founding_crash_config_span_e2e; pub mod get_config_cli; +pub mod homebrew_formula; pub mod json_errors; pub mod jupyter_kernel_cleanup_e2e; pub mod lmd_snapshots; diff --git a/scripts/homebrew-formula.sh b/scripts/homebrew-formula.sh new file mode 100755 index 000000000..a855e14c7 --- /dev/null +++ b/scripts/homebrew-formula.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Render the Homebrew formula (Formula/q2.rb in quarto-dev/homebrew-q2) +# for a stable release, from the release's combined checksums file: +# +# scripts/homebrew-formula.sh 0.42.0 checksums.sha256 > q2.rb +# +# Run by the `homebrew-tap` job in .github/workflows/release-pipeline.yml. +# Tests: crates/quarto/tests/integration/homebrew_formula.rs. + +set -euo pipefail + +die() { + echo "homebrew-formula.sh: $*" >&2 + exit 1 +} + +[ $# -eq 2 ] || die "usage: homebrew-formula.sh " +version=$1 +checksums=$2 +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || + die "version must be a bare stable X.Y.Z (got '$version')" +[ -r "$checksums" ] || die "cannot read $checksums" + +# Print one archive's url and sha256 lines, or fail. +archive() { + local file="q2-${version}-$1.tar.gz" sha + sha=$(awk -v f="$file" '{ sub(/\r$/, "") } $2 == f { print $1; exit }' "$checksums") + [[ "$sha" =~ ^[0-9a-f]{64}$ ]] || die "no valid checksum for $file in $checksums" + printf ' url "https://github.com/quarto-dev/q2/releases/download/v%s/%s"\n' "$version" "$file" + printf ' sha256 "%s"\n' "$sha" +} + +darwin_arm64=$(archive darwin_arm64) +darwin_amd64=$(archive darwin_amd64) +linux_arm64=$(archive linux_arm64) +linux_amd64=$(archive linux_amd64) + +cat <