From e3f57e0dfd58d946c88917f9527e02cb72dae6e3 Mon Sep 17 00:00:00 2001 From: Logan Yang Date: Tue, 3 Mar 2026 21:37:02 -0800 Subject: [PATCH] ci: add automated release workflow on PR merge (#2256) * ci: add automated release workflow on PR merge Triggers when a PR targeting master is merged with a strict semver title (e.g., "3.2.3"). Builds the plugin and creates a GitHub Release with main.js, manifest.json, and styles.css attached. Non-semver PR titles are silently skipped. Includes manifest.json version match validation and shell injection protection for release notes. Co-Authored-By: Claude Opus 4.6 * fix: pin release checkout to merge commit SHA Avoids race condition where a concurrent PR merge could cause the release workflow to build a different commit than the one that triggered it. Co-Authored-By: Claude Sonnet 4.6 * fix: prevent shell injection in release workflow and add idempotency check Use env vars instead of inline ${{ }} expansion for PR title and version outputs to prevent shell injection. Add pre-check to skip release creation if the version tag already exists. Co-Authored-By: Claude Opus 4.6 * fix: pin release tag to merge commit SHA via --target flag Ensures gh release create tags exactly the merge commit, preventing a source/binary mismatch if concurrent merges land before the step runs. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/release.yml | 128 ++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..4b046813 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,128 @@ +# Release workflow: triggered when a PR targeting master is merged and its title +# is a strict semver string (e.g. "3.2.3"). The PR title becomes the release tag +# and title; the PR body becomes the release notes. +# +# Non-semver PR titles (feature PRs, bug fixes, etc.) are silently ignored — +# the workflow runs but exits early without creating a release. + +name: Release + +on: + pull_request: + types: [closed] + branches: + - master + +jobs: + release: + # Only proceed when the PR was actually merged (not just closed/abandoned). + if: github.event.pull_request.merged == true + + runs-on: ubuntu-latest + + permissions: + contents: write # required to create GitHub releases and tags + + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + # ── Step 1: Validate that the PR title is a strict semver ────────────── + # Exits without error when the title is not semver so non-release PRs + # pass silently. Sets outputs.version and outputs.is_release for + # downstream steps. + - name: Validate semver PR title + id: semver + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + if echo "$PR_TITLE" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "PR title '$PR_TITLE' is a valid semver — proceeding with release." + echo "version=$PR_TITLE" >> "$GITHUB_OUTPUT" + echo "is_release=true" >> "$GITHUB_OUTPUT" + else + echo "PR title '$PR_TITLE' is not strict semver (X.Y.Z) — skipping release." + echo "is_release=false" >> "$GITHUB_OUTPUT" + fi + + # ── Step 2: Checkout the merge commit on master ───────────────────────── + - name: Checkout + if: steps.semver.outputs.is_release == 'true' + uses: actions/checkout@v4 + with: + # Pin to the exact merge commit SHA so concurrent PRs merging to + # master don't cause this workflow to build the wrong code. + ref: ${{ github.event.pull_request.merge_commit_sha }} + + # ── Step 3: Verify PR title version matches manifest.json ─────────────── + - name: Verify version matches manifest.json + if: steps.semver.outputs.is_release == 'true' + env: + VERSION: ${{ steps.semver.outputs.version }} + run: | + MANIFEST_VERSION=$(node -p "require('./manifest.json').version") + if [ "$VERSION" != "$MANIFEST_VERSION" ]; then + echo "Version mismatch: PR title='$VERSION', manifest.json='$MANIFEST_VERSION'" + exit 1 + fi + echo "Version check passed: $VERSION" + + # ── Step 4: Set up Node.js ─────────────────────────────────────────────── + - name: Setup Node.js 22.x + if: steps.semver.outputs.is_release == 'true' + uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + + # ── Step 5: Install dependencies ──────────────────────────────────────── + - name: Install dependencies + if: steps.semver.outputs.is_release == 'true' + run: npm ci + + # ── Step 6: Build the plugin ───────────────────────────────────────────── + - name: Build + if: steps.semver.outputs.is_release == 'true' + run: npm run build + + # ── Step 7: Write PR body to a file (avoids shell injection) ───────────── + # Using an environment variable to pass the PR body prevents special + # characters (backticks, quotes, dollar signs, etc.) from being + # interpreted by the shell. + - name: Write release notes to file + if: steps.semver.outputs.is_release == 'true' + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: printf '%s' "$PR_BODY" > /tmp/release-notes.md + + # ── Step 8: Check if release already exists (idempotency guard) ───────── + - name: Check if release already exists + if: steps.semver.outputs.is_release == 'true' + id: check_existing + env: + VERSION: ${{ steps.semver.outputs.version }} + run: | + if gh release view "$VERSION" > /dev/null 2>&1; then + echo "::warning::Release $VERSION already exists — skipping." + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + # ── Step 9: Create GitHub Release ──────────────────────────────────────── + # --target pins the tag to the exact merge commit SHA so concurrent + # merges cannot cause the release tag to point at a different commit. + # Artifacts: main.js, manifest.json, styles.css + - name: Create GitHub Release + if: steps.semver.outputs.is_release == 'true' && steps.check_existing.outputs.exists != 'true' + env: + VERSION: ${{ steps.semver.outputs.version }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + gh release create "$VERSION" \ + --target "$MERGE_SHA" \ + --title "$VERSION" \ + --notes-file /tmp/release-notes.md \ + main.js \ + manifest.json \ + styles.css