Skip to content
Merged
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
121 changes: 121 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ jobs:
timeout-minutes: 15
outputs:
release_commit: ${{ steps.release-source.outputs.release_commit }}
homebrew_eligible: ${{ steps.release-plan.outputs.homebrew_eligible }}
env:
TAG_NAME: ${{ inputs.tag }}
steps:
Expand Down Expand Up @@ -69,9 +70,29 @@ jobs:
python scripts/release.py release-artifacts

- name: Verify GitHub release inputs
id: release-plan
run: |
plan_file="$RUNNER_TEMP/github-release-plan.txt"
uv run --locked --extra dev \
python scripts/release.py github-release-plan \
--expected-tag "$TAG_NAME" > "$plan_file"
cat "$plan_file"
mapfile -t plan < "$plan_file"
if [[ "${#plan[@]}" -ne 3 \
|| ! "${plan[0]}" =~ ^prerelease=(true|false)$ \
|| ! "${plan[1]}" =~ ^latest=(true|false)$ \
|| ! "${plan[2]}" =~ ^notes_start_tag=(v[0-9A-Za-z._!-]+)?$ ]]
then
echo "The verified release plan returned malformed output." >&2
exit 1
fi
echo "homebrew_eligible=${plan[1]#latest=}" >> "$GITHUB_OUTPUT"

- name: Prepare Homebrew tap formula
if: steps.release-plan.outputs.homebrew_eligible == 'true'
run: |
uv run --locked --extra dev \
python scripts/release.py homebrew-formula \
--expected-tag "$TAG_NAME"

- name: Upload verified release bundle
Expand All @@ -87,6 +108,17 @@ jobs:
overwrite: true
retention-days: 30

- name: Upload verified Homebrew formula
if: steps.release-plan.outputs.homebrew_eligible == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: homebrew-formula
path: .release/homebrew/Formula/crewplane.rb
include-hidden-files: true
if-no-files-found: error
overwrite: true
retention-days: 30

github-release:
name: github-release
needs: [verify]
Expand Down Expand Up @@ -134,3 +166,92 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: scripts/publish_github_release.sh dist

homebrew-pr:
name: homebrew-pr
needs: [verify, github-release]
if: needs.verify.outputs.homebrew_eligible == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
env:
TAG_NAME: ${{ inputs.tag }}
SOURCE_COMMIT: ${{ needs.verify.outputs.release_commit }}
steps:
- name: Check out verified release commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ needs.verify.outputs.release_commit }}
fetch-depth: 0
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"

- name: Install uv
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version-file: "packaging/uv-bootstrap-version.txt"
enable-cache: false

- name: Install dependencies
run: uv sync --locked --extra dev

- name: Download verified release bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: release-bundle
path: .

- name: Download verified Homebrew formula
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: homebrew-formula
path: .release/homebrew/Formula

- name: Create Homebrew tap token
id: tap-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.HOMEBREW_UPDATER_CLIENT_ID }}
private-key: ${{ secrets.HOMEBREW_UPDATER_PRIVATE_KEY }}
owner: crewplaneai
repositories: homebrew-crewplane
permission-contents: write
permission-pull-requests: write

- name: Check out Homebrew tap
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
repository: crewplaneai/homebrew-crewplane
ref: main
fetch-depth: 0
path: homebrew-tap
token: ${{ steps.tap-token.outputs.token }}
persist-credentials: false

- name: Configure Homebrew tap authentication
env:
GH_TOKEN: ${{ steps.tap-token.outputs.token }}
APP_SLUG: ${{ steps.tap-token.outputs.app-slug }}
run: |
app_user="${APP_SLUG}[bot]"
app_user_id="$(gh api "/users/$app_user" --jq .id)"
git -C homebrew-tap config user.name "$app_user"
git -C homebrew-tap config user.email \
"${app_user_id}+${app_user}@users.noreply.github.com"
gh auth setup-git

- name: Publish Homebrew pull request
env:
GH_TOKEN: ${{ steps.tap-token.outputs.token }}
run: |
uv run --locked --extra dev \
python scripts/release.py publish-homebrew-pr \
--expected-tag "$TAG_NAME" \
--source-commit "$SOURCE_COMMIT" \
--tap-root homebrew-tap \
--execute
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable user-facing changes are recorded here.

## [Unreleased]

### Changed

- Stable releases now open a Homebrew tap pull request automatically; bottle
publication remains a manual `brew pr-pull` step.

## [0.2.0] - 2026-08-23

### Breaking Changes
Expand Down
46 changes: 27 additions & 19 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,10 @@ Current CI policy:
- Default branch: `master`.
- The supported platform matrix is defined in
[Supported Platforms](#supported-platforms).
- Production publishing is local-only. Follow the
[Release Workflow](#release-workflow). GitHub Actions does not publish
production PyPI or npm packages and does not need their credentials.
- Production PyPI and npm publishing is local-only. Follow the
[Release Workflow](#release-workflow). After `make release` publishes the
packages and Git tag, the source repository's release Action publishes the
GitHub Release and opens the Homebrew pull request.
- Workflow actions and `uv` are version-pinned. `packaging/uv-bootstrap.json`
is the source of truth for the `uv` version and installer checksums. The
updater generates `packaging/uv-bootstrap-version.txt` from this manifest for
Expand Down Expand Up @@ -164,9 +165,10 @@ During the public-alpha `0.x` period, support the current schema only. Persisted

## Release Workflow

Production releases have two publication phases: publish the packages and Git
tag locally, then publish the GitHub Release. Use `make help` for target
details.
Production releases publish PyPI, npm, and the Git tag locally. The release
Action then publishes the GitHub Release and opens a Homebrew pull request for
the newest stable release. Publishing the tested bottles remains a manual
`brew pr-pull` step.

### 1. Prepare and validate

Expand Down Expand Up @@ -200,22 +202,28 @@ For a non-interactive npm release that requires two-factor authentication, set

### 3. Publish the GitHub Release

Immediately after `make release` pushes the tag, dispatch
`.github/workflows/release.yml` from `master`. Use the full Git tag, including
the `v` prefix, as the required `tag` input: for package version `0.1.4`, enter
`v0.1.4`, not `0.1.4`. Do this before `master` advances: dispatching from
another ref, backfilling a historical tag, or dispatching after a newer commit
reaches `master` is unsupported.
After `make release` pushes the tag, run the source repository's `release`
GitHub Action from `master`. Enter the full Git tag, including the `v` prefix:
for package version `0.1.4`, enter `v0.1.4`.
Dispatch it before `master` advances; the tag must point to the currently
selected `master` commit.

The workflow verifies the tagged release and publishes the GitHub Release. It
does not publish the production PyPI or npm packages. Prereleases are never
marked as GitHub `Latest`; a stable release is marked `Latest` only when it is
the highest published stable version on PyPI.
The Action publishes the GitHub Release. For the newest stable release, it also
automatically opens a pull request in `crewplaneai/homebrew-crewplane`.

### 4. Publish Homebrew separately
### 4. Publish the tested Homebrew pull request

Copy the prepared formula into the Homebrew tap, run the tap's audit and test
steps, and push the tap update.
1. Wait for both the macOS and Linux `brew test-bot` checks to pass and upload
their bottles.
2. Do **not** click the pull request's normal Merge button.
3. In `homebrew-crewplane`, run the `brew pr-pull` Action with:

- The pull request number.
- Preferably the pull request's current head SHA, which prevents publishing
a revision that was not tested.

The `brew pr-pull` Action collects the tested bottles, updates the formula's
bottle metadata, and pushes the completed release to `main`.

### Recover an interrupted release

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ help:
' NPM_DIST_TAG_OTP npm one-time password for npm dist-tag add in non-TTY mode' \
' NPM_PUBLISH_ARGS Extra arguments passed to npm publish and dist-tag' \
'' \
'Homebrew tap publishing is separate: copy the prepared formula into the tap, audit/test there, and push the tap update.'
'Eligible GitHub releases open a Homebrew tap PR; publish it with the tap brew pr-pull workflow after checks pass.'

setup:
$(INSTALL_CMD)
Expand Down
1 change: 1 addition & 0 deletions packaging/homebrew/Formula/crewplane.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Crewplane < Formula

depends_on "maturin" => :build
depends_on "rust" => :build
depends_on "libyaml"
depends_on "python@3.13"

resource "hatchling" do
Expand Down
20 changes: 19 additions & 1 deletion scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from pathlib import Path

from markdown_it import MarkdownIt
from release import build, publish, smoke
from release import build, homebrew, publish, smoke
from release.state import (
CommandRunner,
ReleaseError,
Expand Down Expand Up @@ -59,9 +59,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
"--expected-tag",
help="The tag expected by the release workflow. Must match context.version.tag.",
)
homebrew_formula_parser = subparsers.add_parser("homebrew-formula")
homebrew_formula_parser.add_argument("--expected-tag", required=True)
for command in ("publish-pypi", "publish-npm", "finalize"):
command_parser = subparsers.add_parser(command)
command_parser.add_argument("--execute", action="store_true")
homebrew_pr_parser = subparsers.add_parser("publish-homebrew-pr")
homebrew_pr_parser.add_argument("--expected-tag", required=True)
homebrew_pr_parser.add_argument("--source-commit", required=True)
homebrew_pr_parser.add_argument("--tap-root", type=Path, required=True)
homebrew_pr_parser.add_argument("--execute", action="store_true")
return parser.parse_args(argv)


Expand Down Expand Up @@ -90,6 +97,9 @@ def dispatch(args: argparse.Namespace, root: Path, runner: CommandRunner) -> int
if command == "github-release-plan":
publish.print_github_release_plan(root, runner, args.expected_tag)
return 0
if command == "homebrew-formula":
homebrew.prepare_formula(root, args.expected_tag)
return 0
if command == "confirm":
publish.confirm_release(root)
return 0
Expand All @@ -99,6 +109,14 @@ def dispatch(args: argparse.Namespace, root: Path, runner: CommandRunner) -> int
return publish.publish_npm(root, runner, bool(args.execute))
if command == "finalize":
return publish.finalize_release(root, runner, bool(args.execute))
if command == "publish-homebrew-pr":
options = homebrew.HomebrewPrOptions(
expected_tag=args.expected_tag,
source_commit=args.source_commit,
tap_root=args.tap_root,
execute=bool(args.execute),
)
return homebrew.publish_formula_pull_request(root, runner, options)
if command == "package-build":
build.package_build(root, runner)
return 0
Expand Down
7 changes: 3 additions & 4 deletions scripts/release/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,6 @@ def print_homebrew_instructions(context: ReleaseContext) -> None:
print("Homebrew formula ready:")
print(f" {formula}")
print("")
print("Copy it to:")
print(f" crewplaneai/homebrew-crewplane/Formula/{context.package_name}.rb")
print("")
print("Then run brew audit/test in the tap repository and push the tap update.")
print("After publishing the Git tag, run the GitHub release workflow.")
print("Eligible releases open a Homebrew tap pull request automatically.")
print("After its checks pass, publish it with the tap's brew pr-pull workflow.")
Loading