diff --git a/.changeset/quiet-waves-release.md b/.changeset/quiet-waves-release.md new file mode 100644 index 0000000..90aaae5 --- /dev/null +++ b/.changeset/quiet-waves-release.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Harden the automated release workflow. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 19d44bd..a091d70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,8 +26,9 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: main + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: false - name: Check for changesets id: check @@ -41,10 +42,181 @@ jobs: echo "No changesets to release" fi - notify-approval-needed: - name: Notify Slack - Approval Needed + prepare-release-candidate: + name: Prepare release candidate needs: check-changesets + runs-on: ubuntu-latest if: needs.check-changesets.outputs.has-changesets == 'true' + permissions: + contents: read + outputs: + new-version: ${{ steps.candidate.outputs.new-version }} + patch-sha256: ${{ steps.candidate.outputs.patch-sha256 }} + source-sha: ${{ steps.candidate.outputs.source-sha }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup pnpm and Node.js + uses: pnpm/setup@f7d0e5f4b1b3089d2799ef9722859e7ba314c4c8 # v1 + with: + # pnpm/setup installs runtimes via pnpm runtime, which requires pnpm >=11.1.0. + version: 11.7.0 + runtime: node@24 + cache: true + install: false + + - name: Install release tooling dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Apply changesets and update version + id: candidate + env: + # If scripts/bump-version.sh changes, recompute this with: + # sha256sum scripts/bump-version.sh + # Then update EXPECTED_BUMP_SCRIPT_SHA256 in this workflow in the same PR. + EXPECTED_BUMP_SCRIPT_SHA256: fb70c4da11c73df4da1b5b8d3cbd7e483cd19525dc9d8045f6c53a1bd1546809 + run: | + set -euo pipefail + + source_sha=$(git rev-parse HEAD) + pnpm exec changeset version + new_version=$(node -p "require('./package.json').version") + + actual_bump_script_sha256=$(sha256sum scripts/bump-version.sh | awk '{print $1}') + if [ "$actual_bump_script_sha256" != "$EXPECTED_BUMP_SCRIPT_SHA256" ]; then + echo "scripts/bump-version.sh sha256 mismatch: expected $EXPECTED_BUMP_SCRIPT_SHA256, got $actual_bump_script_sha256" >&2 + exit 1 + fi + + ./scripts/bump-version.sh "$new_version" + + if [ -z "$(git status --porcelain)" ]; then + echo "No release candidate changes were generated" >&2 + exit 1 + fi + + changed_files=$(git diff --name-only) + while IFS= read -r file; do + case "$file" in + CHANGELOG.md|composer.json|package.json|lib/PostHog.php|.changeset/*.md) ;; + *) + echo "Unexpected release candidate change: $file" >&2 + exit 1 + ;; + esac + done <<< "$changed_files" + + composer_version=$(perl -0ne 'print $1 if /"version"\s*:\s*"([^"]+)"/' composer.json) + php_version=$(perl -0ne "print \$1 if /public const VERSION = '([^']+)'/" lib/PostHog.php) + if [ "$new_version" != "$composer_version" ] || [ "$new_version" != "$php_version" ]; then + echo "Version mismatch: package.json=$new_version composer.json=$composer_version lib/PostHog.php=$php_version" >&2 + exit 1 + fi + + git diff --binary > release.patch + patch_sha256=$(sha256sum release.patch | awk '{print $1}') + + echo "source-sha=$source_sha" >> "$GITHUB_OUTPUT" + echo "new-version=$new_version" >> "$GITHUB_OUTPUT" + echo "patch-sha256=$patch_sha256" >> "$GITHUB_OUTPUT" + echo "Release candidate $new_version prepared from $source_sha with patch sha256 $patch_sha256" + + - name: Upload release candidate patch + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-candidate-${{ steps.candidate.outputs.source-sha }} + path: release.patch + if-no-files-found: error + retention-days: 5 + + verify-release-candidate: + name: Verify release candidate + needs: [check-changesets, prepare-release-candidate] + runs-on: ubuntu-latest + if: needs.check-changesets.outputs.has-changesets == 'true' + permissions: + actions: read + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ needs.prepare-release-candidate.outputs.source-sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Download release candidate patch + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-candidate-${{ needs.prepare-release-candidate.outputs.source-sha }} + path: release-candidate + + - name: Apply and validate release candidate patch + env: + EXPECTED_VERSION: ${{ needs.prepare-release-candidate.outputs.new-version }} + run: | + set -euo pipefail + + git apply --index release-candidate/release.patch + + package_version=$(perl -0ne 'print $1 if /"version"\s*:\s*"([^"]+)"/' package.json) + composer_version=$(perl -0ne 'print $1 if /"version"\s*:\s*"([^"]+)"/' composer.json) + php_version=$(perl -0ne "print \$1 if /public const VERSION = '([^']+)'/" lib/PostHog.php) + if [ "$EXPECTED_VERSION" != "$package_version" ] || [ "$EXPECTED_VERSION" != "$composer_version" ] || [ "$EXPECTED_VERSION" != "$php_version" ]; then + echo "Version mismatch: expected=$EXPECTED_VERSION package.json=$package_version composer.json=$composer_version lib/PostHog.php=$php_version" >&2 + exit 1 + fi + + - name: Check tag and release do not already exist + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_VERSION: ${{ needs.prepare-release-candidate.outputs.new-version }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + check_missing() { + local description="$1" + shift + local output + local status + + if output=$("$@" 2>&1); then + echo "$description already exists for $EXPECTED_VERSION" >&2 + exit 1 + else + status=$? + fi + + if grep -q "HTTP 404" <<< "$output" || grep -q "Not Found" <<< "$output"; then + return 0 + fi + + echo "Could not check whether $description exists: $output" >&2 + exit "$status" + } + + check_missing "GitHub release" gh api "repos/$REPOSITORY/releases/tags/$EXPECTED_VERSION" + check_missing "git tag" gh api "repos/$REPOSITORY/git/ref/tags/$EXPECTED_VERSION" + + - name: Set up PHP 8.4 + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.37.0 + with: + php-version: 8.4 + tools: composer + + - name: Validate composer files + run: composer validate --no-check-lock --no-check-version --strict + + notify-approval-needed: + name: Notify Slack - Approval Needed + needs: [check-changesets, prepare-release-candidate, verify-release-candidate] + if: needs.check-changesets.outputs.has-changesets == 'true' && needs.prepare-release-candidate.result == 'success' && needs.verify-release-candidate.result == 'success' uses: posthog/.github/.github/workflows/notify-approval-needed.yml@5fc4680761e8ac29a61b212756230eba0e276d8c with: slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }} @@ -54,116 +226,88 @@ jobs: posthog_project_api_key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} release: - name: Bump version and release - needs: [check-changesets, notify-approval-needed] + name: Publish release + needs: [check-changesets, prepare-release-candidate, verify-release-candidate, notify-approval-needed] runs-on: ubuntu-latest - if: always() && needs.check-changesets.outputs.has-changesets == 'true' + if: always() && needs.check-changesets.outputs.has-changesets == 'true' && needs.verify-release-candidate.result == 'success' && needs.notify-approval-needed.result == 'success' environment: "Release" permissions: - contents: write - actions: write + actions: read + contents: read steps: - - name: Notify Slack - Approved - if: needs.notify-approval-needed.outputs.slack_ts != '' - uses: posthog/.github/.github/actions/slack-thread-reply@5fc4680761e8ac29a61b212756230eba0e276d8c - with: - slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }} - slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }} - thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }} - message: "✅ Release approved! Version bump in progress..." - emoji_reaction: "white_check_mark" - - name: Get GitHub App token - id: releaser - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ secrets.GH_APP_POSTHOG_PHP_RELEASER_APP_ID }} - private-key: ${{ secrets.GH_APP_POSTHOG_PHP_RELEASER_PRIVATE_KEY }} - - - name: Checkout repository + - name: Checkout approved source revision uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: main + ref: ${{ needs.prepare-release-candidate.outputs.source-sha }} fetch-depth: 0 - token: ${{ steps.releaser.outputs.token }} + persist-credentials: false - - name: Configure Git + - name: Download verified release candidate patch + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-candidate-${{ needs.prepare-release-candidate.outputs.source-sha }} + path: release-candidate + + - name: Apply verified release candidate patch + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_PATCH_SHA256: ${{ needs.prepare-release-candidate.outputs.patch-sha256 }} + EXPECTED_SOURCE_SHA: ${{ needs.prepare-release-candidate.outputs.source-sha }} + EXPECTED_VERSION: ${{ needs.prepare-release-candidate.outputs.new-version }} run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" + set -euo pipefail - - name: Setup pnpm and Node.js - uses: pnpm/setup@f7d0e5f4b1b3089d2799ef9722859e7ba314c4c8 # v1 - with: - # pnpm/setup installs runtimes via pnpm runtime, which requires pnpm >=11.1.0. - version: 11.7.0 - runtime: node@24 - cache: true - install: false + current_main_sha=$(gh api "repos/${{ github.repository }}/git/ref/heads/main" --jq '.object.sha') + if [ "$current_main_sha" != "$EXPECTED_SOURCE_SHA" ]; then + echo "main moved since the release candidate was prepared: expected $EXPECTED_SOURCE_SHA, got $current_main_sha" >&2 + exit 1 + fi - - name: Install dependencies - run: pnpm install --frozen-lockfile + actual_patch_sha256=$(sha256sum release-candidate/release.patch | awk '{print $1}') + if [ "$actual_patch_sha256" != "$EXPECTED_PATCH_SHA256" ]; then + echo "Release candidate patch sha256 mismatch: expected $EXPECTED_PATCH_SHA256, got $actual_patch_sha256" >&2 + exit 1 + fi - - name: Apply changesets and update version - id: apply-changesets - run: | - pnpm changeset version - NEW_VERSION=$(node -p "require('./package.json').version") - ./scripts/bump-version.sh "$NEW_VERSION" - echo "new-version=$NEW_VERSION" >> "$GITHUB_OUTPUT" - echo "New version: $NEW_VERSION" - - - name: Check for version bump changes - id: check-changes - run: | - if [ -z "$(git status --porcelain)" ]; then - echo "No changes to commit" - echo "committed=false" >> "$GITHUB_OUTPUT" - else - echo "committed=true" >> "$GITHUB_OUTPUT" + git apply --index release-candidate/release.patch + + package_version=$(perl -0ne 'print $1 if /"version"\s*:\s*"([^"]+)"/' package.json) + composer_version=$(perl -0ne 'print $1 if /"version"\s*:\s*"([^"]+)"/' composer.json) + php_version=$(perl -0ne "print \$1 if /public const VERSION = '([^']+)'/" lib/PostHog.php) + if [ "$EXPECTED_VERSION" != "$package_version" ] || [ "$EXPECTED_VERSION" != "$composer_version" ] || [ "$EXPECTED_VERSION" != "$php_version" ]; then + echo "Version mismatch: expected=$EXPECTED_VERSION package.json=$package_version composer.json=$composer_version lib/PostHog.php=$php_version" >&2 + exit 1 fi + rm -rf release-candidate + + - name: Get GitHub App token + id: releaser + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.GH_APP_POSTHOG_PHP_RELEASER_APP_ID }} + private-key: ${{ secrets.GH_APP_POSTHOG_PHP_RELEASER_PRIVATE_KEY }} + - name: Commit version bump id: commit-version-bump - if: steps.check-changes.outputs.committed == 'true' uses: planetscale/ghcommit-action@25309d8005ac7c3bcd61d3fe19b69e0fe47dbdde # v0.2.20 with: - commit_message: "chore: release ${{ steps.apply-changesets.outputs.new-version }} [version bump] [skip ci]" + commit_message: "chore: release ${{ needs.prepare-release-candidate.outputs.new-version }} [version bump] [skip ci]" repo: ${{ github.repository }} branch: main + file_pattern: "CHANGELOG.md composer.json package.json lib/PostHog.php .changeset" env: GITHUB_TOKEN: ${{ steps.releaser.outputs.token }} + - name: Create GitHub release if: steps.commit-version-bump.outputs.commit-hash != '' env: GH_TOKEN: ${{ steps.releaser.outputs.token }} - NEW_VERSION: ${{ steps.apply-changesets.outputs.new-version }} + NEW_VERSION: ${{ needs.prepare-release-candidate.outputs.new-version }} + COMMIT_SHA: ${{ steps.commit-version-bump.outputs.commit-hash }} run: | CHANGELOG_ENTRY=$(awk -v defText="see CHANGELOG.md" '/^## /{if (flag) exit; flag=1} flag && /^##$/{exit} flag; END{if (!flag) print defText}' CHANGELOG.md) - gh release create "$NEW_VERSION" --target main --title "$NEW_VERSION" --notes "$CHANGELOG_ENTRY" - - - name: Send failure event to PostHog - if: failure() - uses: PostHog/posthog-github-action@58dea254b598fb5d469c0699c98af8288a7f7650 # v1.2.0 - with: - posthog-token: "${{ secrets.POSTHOG_PROJECT_API_KEY }}" - event: "posthog-php-github-release-workflow-failure" - properties: >- - { - "commitSha": "${{ github.sha }}", - "jobStatus": "${{ job.status }}", - "ref": "${{ github.ref }}", - "version": "${{ steps.apply-changesets.outputs.new-version }}" - } - - - name: Notify Slack - Failed - if: failure() && needs.notify-approval-needed.outputs.slack_ts != '' - uses: posthog/.github/.github/actions/slack-thread-reply@5fc4680761e8ac29a61b212756230eba0e276d8c - with: - slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }} - slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }} - thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }} - message: "❌ Failed to release `posthog-php@${{ steps.apply-changesets.outputs.new-version }}`! " - emoji_reaction: "x" + gh release create "$NEW_VERSION" --target "$COMMIT_SHA" --title "$NEW_VERSION" --notes "$CHANGELOG_ENTRY" notify-released: name: Notify Slack - Released @@ -171,9 +315,6 @@ jobs: runs-on: ubuntu-latest if: always() && needs.release.result == 'success' && needs.notify-approval-needed.outputs.slack_ts != '' steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Notify Slack - Released uses: posthog/.github/.github/actions/slack-thread-reply@5fc4680761e8ac29a61b212756230eba0e276d8c with: @@ -183,14 +324,17 @@ jobs: message: "🚀 posthog-php released successfully!" emoji_reaction: "rocket" - notify-rejected: - name: Notify Slack - Rejected - needs: [release, notify-approval-needed] + notify-release-failed: + name: Notify Slack/PostHog - Release Failed + needs: [prepare-release-candidate, notify-approval-needed, release] runs-on: ubuntu-latest - if: always() && needs.release.result == 'failure' && needs.notify-approval-needed.outputs.slack_ts != '' + if: always() && (needs.release.result == 'failure' || needs.release.result == 'cancelled') && needs.notify-approval-needed.outputs.slack_ts != '' + permissions: + actions: read + contents: read steps: - - name: Check for rejection - id: check-rejection + - name: Check whether release was rejected + id: check-failure env: GH_TOKEN: ${{ github.token }} run: | @@ -212,13 +356,38 @@ jobs: echo "was_rejected=false" >> "$GITHUB_OUTPUT" fi + - name: Send failure event to PostHog + if: steps.check-failure.outputs.was_rejected != 'true' + continue-on-error: true + uses: PostHog/posthog-github-action@58dea254b598fb5d469c0699c98af8288a7f7650 # v1.2.0 + with: + posthog-token: "${{ secrets.POSTHOG_PROJECT_API_KEY }}" + event: "posthog-php-github-release-workflow-failure" + properties: >- + { + "commitSha": "${{ github.sha }}", + "jobStatus": "${{ needs.release.result }}", + "ref": "${{ github.ref }}", + "version": "${{ needs.prepare-release-candidate.outputs.new-version }}" + } + + - name: Notify Slack - Failed + if: steps.check-failure.outputs.was_rejected != 'true' + uses: posthog/.github/.github/actions/slack-thread-reply@5fc4680761e8ac29a61b212756230eba0e276d8c + with: + slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }} + slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }} + thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }} + message: "❌ Release `posthog-php@${{ needs.prepare-release-candidate.outputs.new-version }}` failed or was cancelled! " + emoji_reaction: "x" + - name: Notify Slack - Rejected - if: steps.check-rejection.outputs.was_rejected == 'true' + if: steps.check-failure.outputs.was_rejected == 'true' continue-on-error: true uses: PostHog/.github/.github/actions/slack-thread-reply@5fc4680761e8ac29a61b212756230eba0e276d8c with: slack_bot_token: ${{ secrets.SLACK_CLIENT_LIBRARIES_BOT_TOKEN }} slack_channel_id: ${{ vars.SLACK_APPROVALS_CLIENT_LIBRARIES_CHANNEL_ID }} thread_ts: ${{ needs.notify-approval-needed.outputs.slack_ts }} - message: '${{ steps.check-rejection.outputs.message }}' + message: '${{ steps.check-failure.outputs.message }}' emoji_reaction: 'no_entry_sign' diff --git a/RELEASING.md b/RELEASING.md index 6afe8a9..11dc159 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -21,10 +21,12 @@ After review, merge the PR to `main`. No GitHub release label is required. A push to `main` that includes `.changeset/*.md` changes automatically starts the release workflow. The workflow then: 1. Checks for pending changesets -2. Notifies the client libraries team in Slack for approval -3. Waits for approval from a maintainer via the GitHub `Release` environment -4. The workflow applies Changesets, syncs `lib/PostHog.php` and `composer.json`, tags the release, and creates a GitHub Release. -5. Notifies Slack when the release completes or fails +2. Prepares a release candidate patch for the triggering commit in a read-only job without release secrets, after verifying the release bump script hash +3. Verifies the release candidate in a separate read-only job and fails if the tag or GitHub Release already exists +4. Notifies the client libraries team in Slack for approval only after candidate preparation and verification both succeed +5. Waits for one approval from a maintainer via the GitHub `Release` environment +6. Applies the verified release candidate patch, creates a signed release commit, tags the release, and creates a GitHub Release +7. Notifies Slack and records PostHog failure events from separate follow-up jobs, outside the approved publishing job ### Manual Trigger @@ -43,3 +45,46 @@ Changesets determines the next version from the committed changeset files: ### No changesets found If the release workflow reports that no changesets were found, make sure your PR includes at least one releasable `.changeset/*.md` file. + +### Updating the release bump script + +The release workflow validates `scripts/bump-version.sh` with a hardcoded SHA256 before executing it. If you modify `scripts/bump-version.sh`, recompute its hash and update `EXPECTED_BUMP_SCRIPT_SHA256` in `.github/workflows/release.yml` in the same PR: + +```bash +sha256sum scripts/bump-version.sh +``` + +### Manual recovery after a failed release + +Most failures happen before anything is published. If the workflow fails before the `Commit version bump` step, no commit, tag, or GitHub Release should exist. + +If the signed release commit was created but `Create GitHub release` failed, prefer completing the release manually instead of rolling back: + +```bash +VERSION= +COMMIT_SHA= + +CHANGELOG_ENTRY=$(awk -v defText="see CHANGELOG.md" '/^## /{if (flag) exit; flag=1} flag && /^##$/{exit} flag; END{if (!flag) print defText}' CHANGELOG.md) +gh release create "$VERSION" --target "$COMMIT_SHA" --title "$VERSION" --notes "$CHANGELOG_ENTRY" +``` + +If the wrong GitHub Release or tag was created, delete both before retrying: + +```bash +VERSION= + +gh release delete "$VERSION" --yes --cleanup-tag || true +git push origin ":refs/tags/$VERSION" || true +``` + +If the signed version bump commit itself is wrong and must be undone, revert it with a new commit rather than force-pushing `main`: + +```bash +git switch main +git pull --ff-only +RELEASE_COMMIT= +git revert "$RELEASE_COMMIT" +git push origin main +``` + +After rollback, add or restore the needed changeset and let the release workflow prepare a new release candidate.