From 7208a3d657940bc43e932dfc06e61268eb47a5b3 Mon Sep 17 00:00:00 2001 From: Nathan Nutter Date: Fri, 9 Oct 2026 16:21:00 -0500 Subject: [PATCH] Update the Homebrew cask after publishing Timber releases Verify the packaged Timber.zip release asset before updating the tap's cask version and checksum. Keep the update self-contained so it does not require a task or tool installation in the tap. Call the update workflow directly after the release upload because releases created with GITHUB_TOKEN do not trigger release workflows. Retain manual dispatch for existing releases and retries. --- .github/workflows/release.yml | 11 ++ .github/workflows/update-tap.yaml | 235 ++++++++++++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 .github/workflows/update-tap.yaml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2a06d3..6b97893 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,3 +38,14 @@ jobs: gh release create "$GITHUB_REF_NAME" dist/Timber.zip \ --title "$GITHUB_REF_NAME" \ --generate-notes + + update-tap: + name: Update the Homebrew cask + needs: release + permissions: + contents: read + uses: ./.github/workflows/update-tap.yaml + with: + tag: ${{ github.ref_name }} + secrets: + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/update-tap.yaml b/.github/workflows/update-tap.yaml new file mode 100644 index 0000000..132cc95 --- /dev/null +++ b/.github/workflows/update-tap.yaml @@ -0,0 +1,235 @@ +name: Update Tap + +on: + workflow_call: + inputs: + tag: + description: Release tag to publish to nnutter/homebrew-tap + required: true + type: string + secrets: + APP_PRIVATE_KEY: + required: true + workflow_dispatch: + inputs: + tag: + description: Release tag to publish to nnutter/homebrew-tap + required: true + type: string + +permissions: + contents: read + +concurrency: + group: update-tap + cancel-in-progress: false + +jobs: + update-cask: + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + CASK_NAME: macos-timber + TAP_OWNER: nnutter + TAP_REPOSITORY: homebrew-tap + steps: + - name: Resolve release tag + id: release + env: + TAG: ${{ inputs.tag }} + run: | + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "The release tag must have the form vMAJOR.MINOR.PATCH." >&2 + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Download and verify Timber.zip + id: asset + env: + GH_TOKEN: ${{ github.token }} + UPSTREAM_REPOSITORY: ${{ github.repository }} + TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} + run: | + work="$(mktemp -d "$RUNNER_TEMP/timber-release.XXXXXX")" + export ASSET_DIR="$work" + gh api "repos/${UPSTREAM_REPOSITORY}/releases/tags/${TAG}" > "$work/release.json" + gh release download "$TAG" \ + --repo "$UPSTREAM_REPOSITORY" \ + --pattern Timber.zip \ + --dir "$work" + python3 <<'PY' + import hashlib + import json + import os + from pathlib import Path + import plistlib + import zipfile + + work = Path(os.environ["ASSET_DIR"]) + release = json.loads((work / "release.json").read_text()) + if release["draft"] or release["prerelease"]: + raise SystemExit("Only published stable releases can update the cask.") + if release["tag_name"] != os.environ["TAG"]: + raise SystemExit("The release tag does not match the requested tag.") + assets = [asset for asset in release["assets"] if asset["name"] == "Timber.zip"] + if len(assets) != 1: + raise SystemExit("The release must contain exactly one Timber.zip asset.") + with (work / "Timber.zip").open("rb") as archive: + sha256 = hashlib.file_digest(archive, "sha256").hexdigest() + if assets[0].get("digest") != f"sha256:{sha256}": + raise SystemExit("Timber.zip does not match GitHub's SHA-256 digest, or the digest is absent.") + with zipfile.ZipFile(work / "Timber.zip") as archive: + if archive.testzip() is not None: + raise SystemExit("Timber.zip failed the ZIP integrity check.") + executable = archive.getinfo("Timber.app/Contents/MacOS/Timber") + if executable.is_dir() or executable.file_size == 0: + raise SystemExit("Timber.zip does not contain the Timber executable.") + info = plistlib.loads(archive.read("Timber.app/Contents/Info.plist")) + if info.get("CFBundleShortVersionString") != os.environ["VERSION"]: + raise SystemExit("The app bundle version does not match the release tag.") + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"sha256={sha256}\n") + print(f"Verified Timber.zip: sha256:{sha256}") + PY + + - name: Generate a token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ env.TAP_OWNER }} + repositories: ${{ env.TAP_REPOSITORY }} + permission-contents: write + permission-pull-requests: write + + - name: Check out the tap + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ env.TAP_OWNER }}/${{ env.TAP_REPOSITORY }} + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false + + - name: Update the cask version and checksum + id: needed + env: + VERSION: ${{ steps.release.outputs.version }} + SHA256: ${{ steps.asset.outputs.sha256 }} + run: | + python3 <<'PY' + import os + from pathlib import Path + import re + + path = Path("Casks") / f"{os.environ['CASK_NAME']}.rb" + if not path.is_file(): + raise SystemExit(f"Merge the initial cask into the tap at {path} before running this workflow.") + original = path.read_text() + expected_url = ' url "https://github.com/' + os.environ["GITHUB_REPOSITORY"] + '/releases/download/v#{version}/Timber.zip"' + if expected_url not in original.splitlines(): + raise SystemExit("The cask URL must reference this repository's versioned Timber.zip release asset.") + updated = original + for field, value in (("version", os.environ["VERSION"]), ("sha256", os.environ["SHA256"])): + updated, count = re.subn( + rf'(?m)^ {field} "[^"\n]+"$', + f' {field} "{value}"', + updated, + ) + if count != 1: + raise SystemExit(f"Expected exactly one top-level {field} stanza in {path}.") + needed = updated != original + if needed: + path.write_text(updated) + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"needed={str(needed).lower()}\n") + if not needed: + print(f"{path} already uses the verified version and checksum.") + PY + + - name: Configure git for the app + if: steps.needed.outputs.needed == 'true' + env: + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" + git config user.name "${APP_SLUG}[bot]" + git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" + + - name: Create the cask branch + if: steps.needed.outputs.needed == 'true' + env: + BRANCH_NAME: cask-updates/${{ env.CASK_NAME }}-${{ steps.release.outputs.tag }} + run: git switch --create "$BRANCH_NAME" + + - name: Commit the cask update + if: steps.needed.outputs.needed == 'true' + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + git diff --check + git add "Casks/${CASK_NAME}.rb" + git commit -m "Update ${CASK_NAME} to ${TAG}" + + - name: Push the cask branch + if: steps.needed.outputs.needed == 'true' + env: + BRANCH_NAME: cask-updates/${{ env.CASK_NAME }}-${{ steps.release.outputs.tag }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TAP_OWNER: ${{ env.TAP_OWNER }} + TAP_REPOSITORY: ${{ env.TAP_REPOSITORY }} + run: | + remote="https://x-access-token:${GH_TOKEN}@github.com/${TAP_OWNER}/${TAP_REPOSITORY}.git" + existing_sha="$(git ls-remote "$remote" "refs/heads/${BRANCH_NAME}" | cut -f1)" + git push --force-with-lease="refs/heads/${BRANCH_NAME}:${existing_sha}" \ + "$remote" \ + "HEAD:refs/heads/${BRANCH_NAME}" + + - name: Open a pull request + if: steps.needed.outputs.needed == 'true' + env: + BRANCH_NAME: cask-updates/${{ env.CASK_NAME }}-${{ steps.release.outputs.tag }} + CASK_NAME: ${{ env.CASK_NAME }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TAG: ${{ steps.release.outputs.tag }} + TAP_OWNER: ${{ env.TAP_OWNER }} + TAP_REPOSITORY: ${{ env.TAP_REPOSITORY }} + UPSTREAM_REPOSITORY: ${{ github.repository }} + run: | + tap="${TAP_OWNER}/${TAP_REPOSITORY}" + existing_url="$( + gh pr list \ + --repo "$tap" \ + --head "$BRANCH_NAME" \ + --state open \ + --json url \ + --jq '.[0].url // empty' + )" + if [[ -n "$existing_url" ]]; then + echo "Pull request already exists: $existing_url" + exit 0 + fi + + gh pr create \ + --repo "$tap" \ + --title "Updated ${CASK_NAME} to ${TAG}" \ + --head "$BRANCH_NAME" \ + --base "$(gh repo view "$tap" --json defaultBranchRef --jq .defaultBranchRef.name)" \ + --body "Update ${CASK_NAME} to ${TAG} from ${UPSTREAM_REPOSITORY}." + + - name: Enable auto-merge + if: steps.needed.outputs.needed == 'true' + env: + BRANCH_NAME: cask-updates/${{ env.CASK_NAME }}-${{ steps.release.outputs.tag }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + TAP_OWNER: ${{ env.TAP_OWNER }} + TAP_REPOSITORY: ${{ env.TAP_REPOSITORY }} + run: | + gh pr merge \ + --repo "${TAP_OWNER}/${TAP_REPOSITORY}" \ + --merge \ + --auto \ + "$BRANCH_NAME"