From eaa60cc2b571f7b148fbeafcc4ffbf7804a0ae16 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:10:03 +0200 Subject: [PATCH] fix(release): resolve package tags once --- .github/workflows/update-packages.yml | 69 ++++++++++++++++--- scripts/check_update_packages_workflow.py | 82 +++++++++++++++++++++++ 2 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 scripts/check_update_packages_workflow.py diff --git a/.github/workflows/update-packages.yml b/.github/workflows/update-packages.yml index fb5cde50..62cb6d22 100644 --- a/.github/workflows/update-packages.yml +++ b/.github/workflows/update-packages.yml @@ -4,12 +4,61 @@ on: release: types: [published] workflow_dispatch: + inputs: + release_tag: + description: Published release tag to update packages for + required: true + type: string jobs: + resolve-release: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.release.outputs.tag }} + steps: + - name: Resolve published release tag + id: release + env: + EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ inputs.release_tag }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + if [ "$GITHUB_EVENT_NAME" = "release" ]; then + REQUESTED_TAG="$EVENT_TAG" + else + REQUESTED_TAG="$INPUT_TAG" + fi + + if [ -z "$REQUESTED_TAG" ]; then + echo "A release tag is required." >&2 + exit 1 + fi + + RELEASE=$(gh release view "$REQUESTED_TAG" --repo "$GITHUB_REPOSITORY" \ + --json tagName,isDraft,publishedAt) + RESOLVED_TAG=$(jq -r '.tagName' <<< "$RELEASE") + IS_DRAFT=$(jq -r '.isDraft' <<< "$RELEASE") + PUBLISHED_AT=$(jq -r '.publishedAt // empty' <<< "$RELEASE") + + if [ "$RESOLVED_TAG" != "$REQUESTED_TAG" ] || \ + [ "$IS_DRAFT" != "false" ] || [ -z "$PUBLISHED_AT" ]; then + echo "Tag '$REQUESTED_TAG' is not an exact published release tag." >&2 + exit 1 + fi + + echo "tag=$RESOLVED_TAG" >> "$GITHUB_OUTPUT" + update-homebrew: + needs: resolve-release runs-on: macos-latest permissions: contents: write + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }} steps: - uses: actions/checkout@v4 with: @@ -19,16 +68,14 @@ jobs: - name: Download and hash release id: release run: | - VERSION="${{ github.event.release.tag_name || github.ref_name }}" curl --fail-with-body -L -o plezy-macos.dmg \ - "https://github.com/edde746/plezy/releases/download/$VERSION/plezy-macos.dmg" + "https://github.com/edde746/plezy/releases/download/$RELEASE_TAG/plezy-macos.dmg" SHA256=$(shasum -a 256 plezy-macos.dmg | cut -d' ' -f1) - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "sha256=$SHA256" >> $GITHUB_OUTPUT + echo "sha256=$SHA256" >> "$GITHUB_OUTPUT" - name: Update cask run: | - sed -i '' "s/version \".*\"/version \"${{ steps.release.outputs.version }}\"/" Casks/plezy.rb + sed -i '' "s/version \".*\"/version \"$RELEASE_TAG\"/" Casks/plezy.rb sed -i '' "s/sha256 \".*\"/sha256 \"${{ steps.release.outputs.sha256 }}\"/" Casks/plezy.rb - name: Commit @@ -38,41 +85,45 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git add Casks/plezy.rb if ! git diff --staged --quiet; then - git commit -m "chore: update cask to ${{ steps.release.outputs.version }}" + git commit -m "chore: update cask to $RELEASE_TAG" git push origin "HEAD:$BRANCH" else echo "No cask changes to commit." fi update-winget: + needs: resolve-release runs-on: windows-latest steps: - uses: vedantmgoyal9/winget-releaser@v2 with: identifier: edde746.Plezy installers-regex: 'plezy-windows-installer\.exe$' + release-tag: ${{ needs.resolve-release.outputs.tag }} token: ${{ secrets.WINGET_TOKEN }} update-appcast-branch: + needs: resolve-release runs-on: ubuntu-latest permissions: contents: write + env: + RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }} steps: - uses: actions/checkout@v4 - name: Download appcast.xml from release run: | - gh release download "${{ github.event.release.tag_name }}" \ + gh release download "$RELEASE_TAG" \ --pattern "appcast.xml" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Push to appcast branch run: | - VERSION="${{ github.event.release.tag_name }}" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" BLOB=$(git hash-object -w appcast.xml) TREE=$(printf "100644 blob %s\tappcast.xml\n" "$BLOB" | git mktree) - COMMIT=$(git commit-tree "$TREE" -m "Update appcast for $VERSION") + COMMIT=$(git commit-tree "$TREE" -m "Update appcast for $RELEASE_TAG") git push origin "$COMMIT:refs/heads/appcast" --force diff --git a/scripts/check_update_packages_workflow.py b/scripts/check_update_packages_workflow.py new file mode 100644 index 00000000..75f6a501 --- /dev/null +++ b/scripts/check_update_packages_workflow.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Guard the release-tag flow in update-packages.yml against regressions.""" + +from pathlib import Path +import re +import sys + + +WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/update-packages.yml" +text = WORKFLOW.read_text(encoding="utf-8") +errors: list[str] = [] + + +def require(condition: bool, message: str) -> None: + if not condition: + errors.append(message) + + +def job(name: str) -> str: + match = re.search( + rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", text + ) + require(match is not None, f"missing {name} job") + return match.group(0) if match else "" + + +require( + re.search( + r"(?ms)^ workflow_dispatch:\n inputs:\n release_tag:\n" + r".*? required: true\n", + text, + ) + is not None, + "workflow_dispatch must require release_tag", +) +require("github.ref_name" not in text, "release tags must never fall back to a branch") +require( + text.count("github.event.release.tag_name") == 1, + "only the resolver may read the release event tag", +) + +resolver = job("resolve-release") +require("gh release view \"$REQUESTED_TAG\"" in resolver, "resolver must validate the tag") +require("--json tagName,isDraft,publishedAt" in resolver, "resolver must require a published release") +require( + "tag: ${{ steps.release.outputs.tag }}" in resolver, + "resolver must expose one validated tag output", +) + +homebrew = job("update-homebrew") +winget = job("update-winget") +appcast = job("update-appcast-branch") +for name, block in ( + ("update-homebrew", homebrew), + ("update-winget", winget), + ("update-appcast-branch", appcast), +): + require("needs: resolve-release" in block, f"{name} must depend on the resolver") + +resolved_output = "${{ needs.resolve-release.outputs.tag }}" +require(f"RELEASE_TAG: {resolved_output}" in homebrew, "Homebrew must use the resolved tag") +require(f"release-tag: {resolved_output}" in winget, "WinGet must use the resolved tag") +require(f"RELEASE_TAG: {resolved_output}" in appcast, "appcast must use the resolved tag") +require( + 'git commit -m "chore: update cask to $RELEASE_TAG"' in homebrew, + "Homebrew commit text must include the resolved tag", +) +require( + 'git commit-tree "$TREE" -m "Update appcast for $RELEASE_TAG"' in appcast, + "appcast commit text must include the resolved tag", +) +require( + 'gh release download "$RELEASE_TAG"' in appcast, + "appcast download must specify the resolved tag", +) + +if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(1) + +print("update-packages workflow release-tag checks passed")