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
74 changes: 59 additions & 15 deletions .github/workflows/release-brew.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
name: release-brew

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Release tag to process (vX.Y.Z). Defaults to v$(cat VERSION)."
required: false
type: string
recovery_ack:
description: "Type I_UNDERSTAND_FALLBACK to run this recovery-only workflow"
required: true
type: string

permissions:
contents: write
Expand All @@ -14,9 +21,42 @@ jobs:
env:
ARCH: arm64
steps:
- name: Validate fallback acknowledgement
run: |
if [[ "${{ inputs.recovery_ack }}" != "I_UNDERSTAND_FALLBACK" ]]; then
echo "release-brew fallback is recovery-only. Set recovery_ack to I_UNDERSTAND_FALLBACK."
exit 1
fi

- name: Checkout
uses: actions/checkout@v4

- name: Resolve release tag
run: |
INPUT_TAG="${{ inputs.tag }}"
if [[ -n "${INPUT_TAG}" ]]; then
TAG="${INPUT_TAG}"
else
VERSION_FROM_FILE="$(tr -d '[:space:]' < VERSION)"
TAG="v${VERSION_FROM_FILE}"
fi

if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid fallback tag '${TAG}'. Expected format vX.Y.Z"
exit 1
fi

echo "TAG=${TAG}" >> "$GITHUB_ENV"

- name: Validate tap token secret
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [[ -z "${HOMEBREW_TAP_TOKEN}" ]]; then
echo "Missing secret HOMEBREW_TAP_TOKEN with write access to EndersonPro/homebrew-flutree"
exit 1
fi

- name: Set up Go
uses: actions/setup-go@v5
with:
Expand All @@ -27,7 +67,7 @@ jobs:

- name: Validate tag matches VERSION file
run: |
./scripts/check_version_contract.sh --tag "${GITHUB_REF_NAME}"
./scripts/check_version_contract.sh --tag "${TAG}"
VERSION="$(tr -d '[:space:]' < VERSION)"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "TARBALL=flutree-${VERSION}-macos-${ARCH}.tar.gz" >> "$GITHUB_ENV"
Expand Down Expand Up @@ -55,19 +95,11 @@ jobs:
- name: Publish release assets
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG }}
files: |
dist/${{ env.TARBALL }}
dist/${{ env.SHAFILE }}

- name: Validate tap token secret
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [[ -z "${HOMEBREW_TAP_TOKEN}" ]]; then
echo "Missing secret HOMEBREW_TAP_TOKEN with write access to EndersonPro/homebrew-flutree"
exit 1
fi

- name: Checkout Homebrew tap repository
uses: actions/checkout@v4
with:
Expand All @@ -81,8 +113,9 @@ jobs:
FORMULA_PATH="homebrew-tap/Formula/flutree.rb"

test -f "$FORMULA_PATH"
sed -i.bak -E "s/^ version \".*\"$/ version \"$VERSION\"/" "$FORMULA_PATH"
sed -i.bak -E "s/^ sha256 \".*\"$/ sha256 \"$SHA256\"/" "$FORMULA_PATH"
sed -i.bak -E "s|^ version \".*\"$| version \"$VERSION\"|" "$FORMULA_PATH"
sed -i.bak -E "s|^ url \".*\"$| url \"https://github.com/EndersonPro/flutree/releases/download/v${VERSION}/flutree-${VERSION}-macos-${ARCH}.tar.gz\"|" "$FORMULA_PATH"
sed -i.bak -E "s|^ sha256 \".*\"$| sha256 \"$SHA256\"|" "$FORMULA_PATH"
rm -f "${FORMULA_PATH}.bak"

- name: Commit and push tap formula update
Expand All @@ -97,3 +130,14 @@ jobs:
fi
git commit -m "chore: release flutree ${VERSION}"
git push origin HEAD:main

- name: Write fallback release summary
if: always()
run: |
{
echo "## release-brew fallback summary"
echo "- TAG: ${TAG:-unknown}"
echo "- VERSION: ${VERSION:-unknown}"
echo "- TARBALL: ${TARBALL:-unknown}"
echo "- SHAFILE: ${SHAFILE:-unknown}"
} >> "$GITHUB_STEP_SUMMARY"
128 changes: 126 additions & 2 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,139 @@ on:
- main

permissions:
contents: write
pull-requests: write
contents: read

jobs:
release-please:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- name: Release Please
id: release
uses: google-github-actions/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json

release-brew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: macos-14
permissions:
contents: write
env:
ARCH: arm64
TAG: ${{ needs.release-please.outputs.tag_name }}
steps:
- name: Validate release outputs
run: |
if [[ -z "${TAG}" ]]; then
echo "Missing tag_name output from release-please job"
exit 1
fi
if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid TAG output from release-please job: ${TAG}"
exit 1
fi

- name: Validate tap token secret
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [[ -z "${HOMEBREW_TAP_TOKEN}" ]]; then
echo "Missing secret HOMEBREW_TAP_TOKEN with write access to EndersonPro/homebrew-flutree"
exit 1
fi

- name: Checkout
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"

- name: Download Go modules
run: go mod download

- name: Validate tag matches VERSION file
run: |
./scripts/check_version_contract.sh --tag "${TAG}"
VERSION="$(tr -d '[:space:]' < VERSION)"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
echo "TARBALL=flutree-${VERSION}-macos-${ARCH}.tar.gz" >> "$GITHUB_ENV"
echo "SHAFILE=flutree-${VERSION}-macos-${ARCH}.sha256" >> "$GITHUB_ENV"

- name: Validate naming contract from packaging script
run: |
CONTRACT_OUTPUT="$(VERSION="$VERSION" ARCH="$ARCH" ./scripts/package_macos.sh contract)"
EXPECTED_TARBALL="$(printf '%s\n' "$CONTRACT_OUTPUT" | sed -n '1p')"
EXPECTED_SHAFILE="$(printf '%s\n' "$CONTRACT_OUTPUT" | sed -n '2p')"
test "$EXPECTED_TARBALL" = "$TARBALL"
test "$EXPECTED_SHAFILE" = "$SHAFILE"

- name: Build artifacts
run: VERSION="$VERSION" ARCH="$ARCH" ./scripts/package_macos.sh build

- name: Verify required artifacts exist
run: |
test -f "dist/$TARBALL"
test -f "dist/$SHAFILE"

- name: Smoke verify binary
run: ./scripts/verify_macos_binary.sh "dist/$TARBALL" --expected-version "$VERSION"

- name: Publish release assets
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.TAG }}
files: |
dist/${{ env.TARBALL }}
dist/${{ env.SHAFILE }}

- name: Checkout Homebrew tap repository
uses: actions/checkout@v4
with:
repository: EndersonPro/homebrew-flutree
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: homebrew-tap

- name: Update tap formula to released version
run: |
SHA256="$(cat "dist/$SHAFILE")"
FORMULA_PATH="homebrew-tap/Formula/flutree.rb"

test -f "$FORMULA_PATH"
sed -i.bak -E "s|^ version \".*\"$| version \"$VERSION\"|" "$FORMULA_PATH"
sed -i.bak -E "s|^ url \".*\"$| url \"https://github.com/EndersonPro/flutree/releases/download/v${VERSION}/flutree-${VERSION}-macos-${ARCH}.tar.gz\"|" "$FORMULA_PATH"
sed -i.bak -E "s|^ sha256 \".*\"$| sha256 \"$SHA256\"|" "$FORMULA_PATH"
rm -f "${FORMULA_PATH}.bak"

- name: Commit and push tap formula update
run: |
cd homebrew-tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/flutree.rb
if git diff --cached --quiet; then
echo "Tap formula already up to date."
exit 0
fi
git commit -m "chore: release flutree ${VERSION}"
git push origin HEAD:main

- name: Write release summary
if: always()
run: |
{
echo "## release-brew summary"
echo "- TAG: ${TAG}"
echo "- VERSION: ${VERSION:-unknown}"
echo "- TARBALL: ${TARBALL:-unknown}"
echo "- SHAFILE: ${SHAFILE:-unknown}"
} >> "$GITHUB_STEP_SUMMARY"
48 changes: 40 additions & 8 deletions docs/release-brew.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,45 @@ Formula URL/checksum naming contract:

1. Merge the Release Please PR generated by `.github/workflows/release-please.yml`.
2. Ensure repository secret `HOMEBREW_TAP_TOKEN` is set in `EndersonPro/flutree` with push access to `EndersonPro/homebrew-flutree`.
3. Wait for `.github/workflows/release-brew.yml` (triggered by the new tag) to publish tarball + sha256 assets and auto-update the tap formula.
3. Wait for `.github/workflows/release-please.yml` to complete both jobs:
- `release-please` (creates/updates release and exposes outputs)
- `release-brew` (runs only when `release_created == true`)
4. Confirm workflow steps passed:
- release asset publish
- tap formula update commit/push
- release asset publish
- tap formula update commit/push
5. Validate install on clean macOS ARM machine:
- `brew tap EndersonPro/flutree`
- `brew install EndersonPro/flutree/flutree`
- `flutree --version` matches repository `VERSION`
6. Transition fallback (single release cycle only): if Release Please automation is unavailable, create manual tag `v$(cat VERSION)` and run the same contract checks before publishing.
7. If automation fails after publish, manually patch `Formula/flutree.rb` in `homebrew-flutree` and rerun install validation.
- `brew tap EndersonPro/flutree`
- `brew install EndersonPro/flutree/flutree`
- `flutree --version` matches repository `VERSION`
6. Review `GITHUB_STEP_SUMMARY` from `release-brew` for traceability (`TAG`, `VERSION`, tarball, sha file).

## Required permissions and secrets

- `release-please` job permissions:
- `contents: write`
- `pull-requests: write`
- `release-brew` job permissions:
- `contents: write`
- Secret:
- `HOMEBREW_TAP_TOKEN`: Personal access token with write access to `EndersonPro/homebrew-flutree`.

Both workflows fail fast when required release outputs or `HOMEBREW_TAP_TOKEN` are missing.

## Recovery fallback workflow

`.github/workflows/release-brew.yml` is now **manual-only** (`workflow_dispatch`) and must be used only for recovery.

Fallback inputs:
- `tag` (optional): `vX.Y.Z`; if omitted it defaults to `v$(cat VERSION)`.
- `recovery_ack` (required): must be exactly `I_UNDERSTAND_FALLBACK`.

This acknowledgement is intentional to avoid accidental duplicate tap updates during the transition.

Fallback run steps:
1. Open Actions -> `release-brew` -> `Run workflow`.
2. Set `recovery_ack=I_UNDERSTAND_FALLBACK`.
3. Set `tag` only if recovering a specific release.
4. Verify the same contract checks pass (`check_version_contract`, packaging contract, smoke verify).
5. Confirm release assets + tap formula push completed.

If automation fails after publish, manually patch `Formula/flutree.rb` in `homebrew-flutree` and rerun install validation.
51 changes: 46 additions & 5 deletions integration/release_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,30 @@ import (
"testing"
)

func TestReleaseWorkflowTracksTagsAndPublishesExpectedArtifacts(t *testing.T) {
func TestReleasePleaseWorkflowOrchestratesBrewPublish(t *testing.T) {
root := projectRoot(t)
workflowPath := filepath.Join(root, ".github", "workflows", "release-brew.yml")
workflowPath := filepath.Join(root, ".github", "workflows", "release-please.yml")
b, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatal(err)
}
content := string(b)

required := []string{
`tags:`,
`- "v*"`,
`./scripts/check_version_contract.sh --tag "${GITHUB_REF_NAME}"`,
`release_created: ${{ steps.release.outputs.release_created }}`,
`tag_name: ${{ steps.release.outputs.tag_name }}`,
`needs: release-please`,
`if: ${{ needs.release-please.outputs.release_created == 'true' }}`,
`TAG: ${{ needs.release-please.outputs.tag_name }}`,
`./scripts/check_version_contract.sh --tag "${TAG}"`,
`VERSION="$(tr -d '[:space:]' < VERSION)"`,
`flutree-${VERSION}-macos-${ARCH}.tar.gz`,
`flutree-${VERSION}-macos-${ARCH}.sha256`,
`tag_name: ${{ env.TAG }}`,
`./scripts/verify_macos_binary.sh "dist/$TARBALL" --expected-version "$VERSION"`,
`repository: EndersonPro/homebrew-flutree`,
`HOMEBREW_TAP_TOKEN`,
`if [[ -z "${TAG}" ]]; then`,
}
for _, token := range required {
if !strings.Contains(content, token) {
Expand All @@ -34,6 +39,42 @@ func TestReleaseWorkflowTracksTagsAndPublishesExpectedArtifacts(t *testing.T) {
}
}

func TestFallbackReleaseWorkflowIsManualAndRecoveryOnly(t *testing.T) {
root := projectRoot(t)
workflowPath := filepath.Join(root, ".github", "workflows", "release-brew.yml")
b, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatal(err)
}
content := string(b)

required := []string{
`workflow_dispatch:`,
`recovery_ack`,
`I_UNDERSTAND_FALLBACK`,
`./scripts/check_version_contract.sh --tag "${TAG}"`,
`tag_name: ${{ env.TAG }}`,
`repository: EndersonPro/homebrew-flutree`,
`HOMEBREW_TAP_TOKEN`,
}
for _, token := range required {
if !strings.Contains(content, token) {
t.Fatalf("fallback workflow missing required token %q", token)
}
}

forbidden := []string{
`push:`,
`tags:`,
`- "v*"`,
}
for _, token := range forbidden {
if strings.Contains(content, token) {
t.Fatalf("fallback workflow contains forbidden trigger token %q", token)
}
}
}

func TestPackageScriptContractUsesGoBuild(t *testing.T) {
root := projectRoot(t)
scriptPath := filepath.Join(root, "scripts", "package_macos.sh")
Expand Down
Loading