Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/release-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions claude-notes/instructions/release-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions crates/quarto/tests/integration/homebrew_formula.rs
Original file line number Diff line number Diff line change
@@ -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");
}
}
1 change: 1 addition & 0 deletions crates/quarto/tests/integration/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions scripts/homebrew-formula.sh
Original file line number Diff line number Diff line change
@@ -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> <checksums.sha256>"
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 <<EOF
# Generated by scripts/homebrew-formula.sh in quarto-dev/q2. Do not edit.
class Q2 < Formula
desc "Quarto 2: scientific and technical publishing system"
homepage "https://github.com/quarto-dev/q2"
version "${version}"
license "MIT"

on_macos do
on_arm do
${darwin_arm64}
end
on_intel do
${darwin_amd64}
end
end

on_linux do
on_arm do
${linux_arm64}
end
on_intel do
${linux_amd64}
end
end

def install
bin.install "q2"
end

test do
assert_match version.to_s, shell_output("#{bin}/q2 --version")
end
end
EOF