diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml new file mode 100644 index 0000000..710a32c --- /dev/null +++ b/.github/workflows/laravel.yml @@ -0,0 +1,75 @@ +name: Laravel CI + +on: + pull_request: + paths: + - 'packages/laravel/**' + - 'examples/laravel/**' + - 'lib/**' + - 'composer.json' + - 'scripts/**' + - 'pnpm-workspace.yaml' + - '.github/workflows/laravel*.yml' + - '.github/workflows/release.yml' + push: + branches: [main] + paths: + - 'packages/laravel/**' + - 'examples/laravel/**' + - 'lib/**' + - 'composer.json' + - 'scripts/**' + - 'pnpm-workspace.yaml' + - '.github/workflows/laravel*.yml' + - '.github/workflows/release.yml' + +permissions: + contents: read + +env: + PYTHONDONTWRITEBYTECODE: '1' + +concurrency: + group: laravel-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + release-tooling: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + with: + version: 11.13.1 + runtime: node@24 + install: false + - run: python3 -m unittest discover -s scripts/tests -v + + framework: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - { laravel: '12', php: '8.2' } + - { laravel: '12', php: '8.3' } + - { laravel: '12', php: '8.4' } + - { laravel: '12', php: '8.5' } + - { laravel: '13', php: '8.3' } + - { laravel: '13', php: '8.4' } + - { laravel: '13', php: '8.5' } + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring, dom, xml, sqlite3, pdo_sqlite + tools: composer + - name: Test package and available example in isolation + env: + LARAVEL: ${{ matrix.laravel }} + run: bash scripts/test-laravel.sh "$LARAVEL" diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index be882bd..431e831 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -166,10 +166,13 @@ jobs: ./vendor/bin/phpcbf --standard=phpcs.xml --no-colors --runtime-set ignore_non_auto_fixable_on_exit 1 git diff --exit-code -- lib test - - uses: tinovyatkin/action-php-codesniffer@0043b33b3629611c37e8bc7ee8a4e061dc9a7ea2 # v1 + - name: Check changed core PHP files if: needs.detect-markdown-only.outputs.markdown_only != 'true' - with: - files: "**.php" # you may customize glob as needed - phpcs_path: ./vendor/bin/phpcs - standard: phpcs.xml - scope: "file" + env: + BASE_REF: ${{ github.base_ref }} + BEFORE_SHA: ${{ github.event.before }} + run: | + range="${BEFORE_SHA:-HEAD^}...HEAD" + if [ -n "$BASE_REF" ]; then range="origin/$BASE_REF...HEAD"; fi + git diff --name-only --diff-filter=ACM -z "$range" -- ':(glob)lib/**/*.php' ':(glob)test/**/*.php' | + xargs -0 -r ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26364d8..17dfc52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,10 @@ on: permissions: contents: read +env: + PYTHONDONTWRITEBYTECODE: '1' + EXPECTED_BUMP_SCRIPT_SHA256: fb70c4da11c73df4da1b5b8d3cbd7e483cd19525dc9d8045f6c53a1bd1546809 + # Concurrency control: only one release process can run at a time # This prevents race conditions if multiple releasable change intents merge simultaneously concurrency: @@ -30,53 +34,18 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Setup pnpm and Node.js + uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + with: + version: 11.13.1 + runtime: node@24 + install: false + - name: Check for change intents id: check env: TRIGGER_SHA: ${{ github.sha }} - run: | - set -euo pipefail - - count_releasable_intents() { - local ref=$1 count=0 path - for path in $(git ls-tree --name-only "$ref" .changeset/); do - [[ "$path" == *.md ]] || continue - if awk -v pattern="^[[:space:]]*['\"]?posthog-php['\"]?[[:space:]]*:[[:space:]]*(patch|minor|major)[[:space:]]*$" ' - NR == 1 { if ($0 != "---") exit 1; next } - $0 == "---" { found_end = 1; exit } - $0 ~ pattern { releasable = 1 } - END { if (!found_end || !releasable) exit 1 } - ' <<< "$(git show "$ref:$path")"; then - count=$((count + 1)) - fi - done - echo "$count" - } - - # Serialized runs can be stale. If main moved on, skip only when no releasable - # intents remain there; a push that doesn't touch .changeset/ starts no release run. - main_sha=$(git rev-parse --verify origin/main) - if [ "$main_sha" != "$TRIGGER_SHA" ]; then - pending_count=$(count_releasable_intents "$main_sha") - if [ "$pending_count" -gt 0 ]; then - echo "main moved from $TRIGGER_SHA to $main_sha with $pending_count releasable change intent(s) still pending; dispatch the Release workflow on main" >&2 - exit 1 - fi - - echo "has-changesets=false" >> "$GITHUB_OUTPUT" - echo "main is at $main_sha, not $TRIGGER_SHA, and has no pending change intents; skipping stale run" - exit 0 - fi - - releasable_count=$(count_releasable_intents "$TRIGGER_SHA") - - if [ "$releasable_count" -gt 0 ]; then - echo "has-changesets=true" >> "$GITHUB_OUTPUT" - echo "Found $releasable_count releasable change intent(s)" - else - echo "has-changesets=false" >> "$GITHUB_OUTPUT" - echo "No releasable change intents found" - fi + run: bash scripts/check-release-changes.sh - name: Send failure event to PostHog if: ${{ failure() }} @@ -101,102 +70,54 @@ jobs: } prepare-release-candidate: - name: Prepare release candidate + name: Prepare dependency-aware release candidate needs: check-changesets - runs-on: ubuntu-latest if: needs.check-changesets.outputs.has-changesets == 'true' - permissions: - contents: read + runs-on: ubuntu-latest outputs: - new-version: ${{ steps.candidate.outputs.new-version }} - patch-sha256: ${{ steps.candidate.outputs.patch-sha256 }} - source-sha: ${{ steps.candidate.outputs.source-sha }} + source: ${{ steps.candidate.outputs.source }} + version: ${{ steps.candidate.outputs.version }} + core-version: ${{ steps.candidate.outputs.core_version }} + core-changed: ${{ steps.candidate.outputs.core_changed }} + patch-sha256: ${{ steps.candidate.outputs.patch_sha256 }} + archive-sha256: ${{ steps.candidate.outputs.archive_sha256 }} + manifest-sha256: ${{ steps.candidate.outputs.manifest_sha256 }} steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - - name: Setup pnpm and Node.js - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 with: - # Native release management requires pnpm >=11.13.0. version: 11.13.1 runtime: node@24 - cache: true install: false - - - name: Set up PHP 8.4 - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 with: - php-version: 8.4 + php-version: '8.4' + extensions: curl, mbstring, dom, xml, sqlite3, pdo_sqlite tools: composer - - - name: Apply change intents and update version + - run: python3 -m unittest discover -s scripts/tests -v + - name: Apply one workspace release plan without publishing credentials 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 + REPOSITORY: ${{ github.repository }} run: | - set -euo pipefail - - source_sha=$(git rev-parse HEAD) - pnpm version -r - git add --intent-to-add .changeset/ledger.yaml - 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" - composer update --lock --no-install --no-interaction --no-audit - - 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|composer.lock|package.json|lib/PostHog.php|.changeset/*) ;; - *) - 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + python3 scripts/release.py prepare --repository "$REPOSITORY" --output "$RUNNER_TEMP/candidate" --bump-script-sha256 "$EXPECTED_BUMP_SCRIPT_SHA256" + python3 - "$RUNNER_TEMP/candidate/candidate.json" >> "$GITHUB_OUTPUT" <<'PY' + import hashlib, json, pathlib, sys + path = pathlib.Path(sys.argv[1]) + for key, value in json.loads(path.read_text()).items(): + print(f'{key}={str(value).lower() if isinstance(value, bool) else value}') + print(f'manifest_sha256={hashlib.sha256(path.read_bytes()).hexdigest()}') + PY + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: release-candidate-${{ steps.candidate.outputs.source-sha }} - path: release.patch + name: release-candidate-${{ steps.candidate.outputs.source }} + path: ${{ runner.temp }}/candidate/ if-no-files-found: error retention-days: 5 - - name: Send failure event to PostHog if: ${{ failure() }} continue-on-error: true @@ -213,92 +134,80 @@ jobs: "sdk": "posthog-php", "jobName": "prepare-release-candidate", "packageName": "posthog-php", - "packageVersion": "${{ steps.candidate.outputs.new-version || 'unknown' }}", + "packageVersion": "${{ steps.candidate.outputs.core_version || 'unknown' }}", "actionUrl": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}", - "alertText": ":rotating_light: Failed to prepare release candidate posthog-php@${{ steps.candidate.outputs.new-version }} :rotating_light:", + "alertText": ":rotating_light: Failed to prepare release candidate posthog-php@${{ steps.candidate.outputs.core_version }} :rotating_light:", "alertContext": "The release candidate preparation step failed before approval or publish." } - verify-release-candidate: - name: Verify release candidate + name: Independently verify both packages needs: [check-changesets, prepare-release-candidate] - runs-on: ubuntu-latest if: needs.check-changesets.outputs.has-changesets == 'true' + runs-on: ubuntu-latest permissions: actions: read contents: read steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.prepare-release-candidate.outputs.source-sha }} + ref: ${{ needs.prepare-release-candidate.outputs.source }} fetch-depth: 0 persist-credentials: false - - - name: Download release candidate patch - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: release-candidate-${{ needs.prepare-release-candidate.outputs.source-sha }} - path: release-candidate - - - name: Apply and validate release candidate patch + name: release-candidate-${{ needs.prepare-release-candidate.outputs.source }} + path: ${{ runner.temp }}/candidate + - uses: pnpm/setup@a0881977cd8ea73b1ed212236c99ca10dcc80cf4 # v3.1.1 + with: + version: 11.13.1 + runtime: node@24 + install: false + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 + with: + php-version: '8.4' + extensions: curl, mbstring, dom, xml, sqlite3, pdo_sqlite + tools: composer + - name: Reproduce every candidate byte and validate the shared version patch env: - EXPECTED_VERSION: ${{ needs.prepare-release-candidate.outputs.new-version }} + REPOSITORY: ${{ github.repository }} 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 + python3 scripts/release.py prepare --repository "$REPOSITORY" --output "$RUNNER_TEMP/reproduced" --bump-script-sha256 "$EXPECTED_BUMP_SCRIPT_SHA256" + for file in candidate.json release.patch package.tar.gz notes.md core-notes.md; do + cmp "$RUNNER_TEMP/candidate/$file" "$RUNNER_TEMP/reproduced/$file" + done + git apply --index "$RUNNER_TEMP/candidate/release.patch" + python3 scripts/release.py validate --candidate "$RUNNER_TEMP/candidate" + - name: Check all planned source and distribution refs are unused env: GH_TOKEN: ${{ github.token }} - EXPECTED_VERSION: ${{ needs.prepare-release-candidate.outputs.new-version }} - REPOSITORY: ${{ github.repository }} + SOURCE_REPOSITORY: ${{ github.repository }} + DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + VERSION: ${{ needs.prepare-release-candidate.outputs.version }} + CORE_VERSION: ${{ needs.prepare-release-candidate.outputs.core-version }} + CORE_CHANGED: ${{ needs.prepare-release-candidate.outputs.core-changed }} + run: bash scripts/check-release-refs.sh + - name: Validate core Composer metadata + run: composer validate --no-check-version --strict + - name: Test both frameworks against the candidate source + run: | + bash scripts/test-laravel.sh 12 "$RUNNER_TEMP/candidate/release.patch" + bash scripts/test-laravel.sh 13 "$RUNNER_TEMP/candidate/release.patch" + - name: Validate distribution using the exact candidate core before it is published 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@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 - with: - php-version: 8.4 - tools: composer - - - name: Validate composer files - run: composer validate --no-check-version --strict - + mkdir "$RUNNER_TEMP/composer-check" + tar -xzf "$RUNNER_TEMP/candidate/package.tar.gz" -C "$RUNNER_TEMP/composer-check" + composer validate --working-dir="$RUNNER_TEMP/composer-check" --strict --no-check-version --no-check-all + python3 - "$RUNNER_TEMP/composer-check/composer.json" "$GITHUB_WORKSPACE" <<'PY' + import json, pathlib, sys + path = pathlib.Path(sys.argv[1]) + data = json.loads(path.read_text()) + data['repositories'] = [{'type': 'path', 'url': sys.argv[2], 'options': {'symlink': False}}] + path.write_text(json.dumps(data, indent=2) + '\n') + PY + composer install --working-dir="$RUNNER_TEMP/composer-check" --no-dev --prefer-dist --no-progress --no-interaction --no-scripts --no-plugins - name: Send failure event to PostHog if: ${{ failure() }} continue-on-error: true @@ -315,13 +224,12 @@ jobs: "sdk": "posthog-php", "jobName": "verify-release-candidate", "packageName": "posthog-php", - "packageVersion": "${{ needs.prepare-release-candidate.outputs.new-version || 'unknown' }}", + "packageVersion": "${{ needs.prepare-release-candidate.outputs.core-version || 'unknown' }}", "actionUrl": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}", - "alertText": ":rotating_light: Failed to verify release candidate posthog-php@${{ needs.prepare-release-candidate.outputs.new-version }} :rotating_light:", + "alertText": ":rotating_light: Failed to verify release candidate posthog-php@${{ needs.prepare-release-candidate.outputs.core-version }} :rotating_light:", "alertContext": "The release candidate verification step failed before approval or publish." } - notify-approval-needed: name: Notify Slack - Approval Needed needs: [check-changesets, prepare-release-candidate, verify-release-candidate] @@ -338,14 +246,16 @@ jobs: posthog_project_api_key: ${{ secrets.POSTHOG_PROJECT_API_KEY }} release: - name: Publish release + name: Commit shared versions and publish core if changed needs: [check-changesets, prepare-release-candidate, verify-release-candidate, notify-approval-needed] + if: always() && !cancelled() && needs.check-changesets.outputs.has-changesets == 'true' && needs.verify-release-candidate.result == 'success' && needs.notify-approval-needed.result == 'success' runs-on: ubuntu-latest - if: always() && needs.check-changesets.outputs.has-changesets == 'true' && needs.verify-release-candidate.result == 'success' && needs.notify-approval-needed.result == 'success' - environment: "Release" + environment: Release permissions: actions: read contents: read + outputs: + commit-hash: ${{ steps.commit-version-bump.outputs.commit-hash }} steps: - name: Notify Slack - Approved if: needs.notify-approval-needed.outputs.slack_ts != '' @@ -354,55 +264,38 @@ jobs: 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! Publishing release..." + message: "✅ Release approved! Committing package versions and publishing..." emoji_reaction: "white_check_mark" - - - name: Checkout approved source revision - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.prepare-release-candidate.outputs.source-sha }} + ref: ${{ needs.prepare-release-candidate.outputs.source }} fetch-depth: 0 persist-credentials: false - - - name: Download verified release candidate patch - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: release-candidate-${{ needs.prepare-release-candidate.outputs.source-sha }} - path: release-candidate - - - name: Apply verified release candidate patch + name: release-candidate-${{ needs.prepare-release-candidate.outputs.source }} + path: ${{ runner.temp }}/candidate + - name: Recheck main, unused refs and all approved artifact digests 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 }} + SOURCE_REPOSITORY: ${{ github.repository }} + DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + VERSION: ${{ needs.prepare-release-candidate.outputs.version }} + CORE_VERSION: ${{ needs.prepare-release-candidate.outputs.core-version }} + CORE_CHANGED: ${{ needs.prepare-release-candidate.outputs.core-changed }} + EXPECTED_SOURCE: ${{ needs.prepare-release-candidate.outputs.source }} + EXPECTED_PATCH: ${{ needs.prepare-release-candidate.outputs.patch-sha256 }} + EXPECTED_ARCHIVE: ${{ needs.prepare-release-candidate.outputs.archive-sha256 }} + EXPECTED_MANIFEST: ${{ needs.prepare-release-candidate.outputs.manifest-sha256 }} run: | set -euo pipefail - - 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 - - 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 - - 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 - + test "$(gh api "repos/$SOURCE_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" = "$EXPECTED_SOURCE" + bash scripts/check-release-refs.sh + echo "$EXPECTED_PATCH $RUNNER_TEMP/candidate/release.patch" | sha256sum --check --strict + echo "$EXPECTED_ARCHIVE $RUNNER_TEMP/candidate/package.tar.gz" | sha256sum --check --strict + echo "$EXPECTED_MANIFEST $RUNNER_TEMP/candidate/candidate.json" | sha256sum --check --strict + git apply --index "$RUNNER_TEMP/candidate/release.patch" + python3 scripts/release.py validate --candidate "$RUNNER_TEMP/candidate" - name: Get GitHub App token id: releaser uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 @@ -410,41 +303,151 @@ jobs: client-id: ${{ secrets.GH_APP_POSTHOG_PHP_RELEASER_APP_ID }} private-key: ${{ secrets.GH_APP_POSTHOG_PHP_RELEASER_PRIVATE_KEY }} permission-contents: write - - - name: Commit version bump + - name: Atomic GitHub-signed shared version commit id: commit-version-bump - uses: planetscale/ghcommit-action@a6b150b81dca5dd027baa898604418eec9e11465 # v0.2.22 - with: - 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 composer.lock package.json lib/PostHog.php .changeset" env: - GITHUB_TOKEN: ${{ steps.releaser.outputs.token }} - - - name: Require release commit + GH_TOKEN: ${{ steps.releaser.outputs.token }} + REPOSITORY: ${{ github.repository }} + run: | + python3 scripts/commit_release.py --checkout "$GITHUB_WORKSPACE" --repository "$REPOSITORY" --mode source --message "chore: update package versions [version bump] [skip ci]" >> "$GITHUB_OUTPUT" + - name: Require verified source signature env: - COMMIT_SHA: ${{ steps.commit-version-bump.outputs.commit-hash }} + GH_TOKEN: ${{ steps.releaser.outputs.token }} + REPOSITORY: ${{ github.repository }} + COMMIT: ${{ steps.commit-version-bump.outputs.commit-hash }} run: | - if [ -z "$COMMIT_SHA" ]; then - echo "Release commit was not created" >&2 - exit 1 - fi - - - name: Create GitHub release + test -n "$COMMIT" + test "$(gh api "repos/$REPOSITORY/commits/$COMMIT" --jq '.commit.verification.verified')" = true + - name: Publish core GitHub release only when its version changed + if: needs.prepare-release-candidate.outputs.core-changed == 'true' env: GH_TOKEN: ${{ steps.releaser.outputs.token }} - NEW_VERSION: ${{ needs.prepare-release-candidate.outputs.new-version }} - COMMIT_SHA: ${{ steps.commit-version-bump.outputs.commit-hash }} + REPOSITORY: ${{ github.repository }} + VERSION: ${{ needs.prepare-release-candidate.outputs.core-version }} + COMMIT: ${{ steps.commit-version-bump.outputs.commit-hash }} + run: gh release create "$VERSION" --repo "$REPOSITORY" --target "$COMMIT" --title "$VERSION" --notes-file "$RUNNER_TEMP/candidate/core-notes.md" + + publish-laravel: + needs: [prepare-release-candidate, release] + if: always() && !cancelled() && needs.release.result == 'success' + runs-on: ubuntu-latest + # Administrators MUST configure required reviewers and main-only deployment rules first. + environment: Laravel Release + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release.outputs.commit-hash }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-candidate-${{ needs.prepare-release-candidate.outputs.source }} + path: ${{ runner.temp }}/candidate + - name: Recheck refs, main and approved candidate digests + env: + GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} + SOURCE_REPOSITORY: ${{ github.repository }} + DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + VERSION: ${{ needs.prepare-release-candidate.outputs.version }} + EXPECTED_SOURCE: ${{ needs.release.outputs.commit-hash }} + EXPECTED_PATCH: ${{ needs.prepare-release-candidate.outputs.patch-sha256 }} + EXPECTED_ARCHIVE: ${{ needs.prepare-release-candidate.outputs.archive-sha256 }} + EXPECTED_MANIFEST: ${{ needs.prepare-release-candidate.outputs.manifest-sha256 }} + DISTRIBUTION_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + run: | + set -euo pipefail + test -n "$GH_TOKEN" && test -n "$DISTRIBUTION_TOKEN" + test "$(gh api "repos/$SOURCE_REPOSITORY/git/ref/heads/main" --jq '.object.sha')" = "$EXPECTED_SOURCE" + bash scripts/check-release-refs.sh + echo "$EXPECTED_PATCH $RUNNER_TEMP/candidate/release.patch" | sha256sum --check --strict + echo "$EXPECTED_ARCHIVE $RUNNER_TEMP/candidate/package.tar.gz" | sha256sum --check --strict + echo "$EXPECTED_MANIFEST $RUNNER_TEMP/candidate/candidate.json" | sha256sum --check --strict + python3 scripts/release.py validate --candidate "$RUNNER_TEMP/candidate" + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2.40.0 + with: + php-version: '8.4' + extensions: curl, mbstring, dom, xml, sqlite3, pdo_sqlite + tools: composer + - name: Wait for the exact published core and test production dependency resolution + env: + CORE_VERSION: ${{ needs.prepare-release-candidate.outputs.core-version }} + run: | + set -euo pipefail + mkdir "$RUNNER_TEMP/production-check" + tar -xzf "$RUNNER_TEMP/candidate/package.tar.gz" -C "$RUNNER_TEMP/production-check" + for attempt in {1..30}; do + if curl --fail --silent --show-error --connect-timeout 10 --max-time 30 https://repo.packagist.org/p2/posthog/posthog-php.json -o "$RUNNER_TEMP/core.json" && + python3 -c 'import json,sys; sys.exit(not any(v["version"].lstrip("v") == sys.argv[1] for v in json.load(open(sys.argv[2]))["packages"]["posthog/posthog-php"]))' "$CORE_VERSION" "$RUNNER_TEMP/core.json"; then + composer install --working-dir="$RUNNER_TEMP/production-check" --no-dev --prefer-dist --no-progress --no-interaction --no-scripts --no-plugins + exit 0 + fi + if [ "$attempt" -eq 30 ]; then + echo "Core $CORE_VERSION is not available on Packagist; Laravel was not published" >&2 + exit 1 + fi + sleep 20 + done + - name: Require signed source commit + env: + GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} + REPOSITORY: ${{ github.repository }} + COMMIT: ${{ needs.release.outputs.commit-hash }} + run: | + test -n "$COMMIT" + test "$(gh api "repos/$REPOSITORY/commits/$COMMIT" --jq '.commit.verification.verified')" = true + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + ref: main + path: laravel-distribution + token: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + persist-credentials: false + - name: Replace dedicated distribution tree with package-only files + env: + REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + run: python3 scripts/release.py install --candidate "$RUNNER_TEMP/candidate" --destination "$GITHUB_WORKSPACE/laravel-distribution" --repository "$REPOSITORY" + - name: Atomic GitHub-signed distribution commit + id: distribution-commit + env: + GH_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + VERSION: ${{ needs.prepare-release-candidate.outputs.version }} + SOURCE: ${{ needs.prepare-release-candidate.outputs.source }} + run: | + python3 scripts/commit_release.py --checkout "$GITHUB_WORKSPACE/laravel-distribution" --repository "$REPOSITORY" --mode distribution --candidate "$RUNNER_TEMP/candidate" --message "Release v$VERSION from source $SOURCE" >> "$GITHUB_OUTPUT" + - name: Publish distribution version + env: + GH_TOKEN: ${{ secrets.LARAVEL_DISTRIBUTION_TOKEN }} + REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + COMMIT: ${{ steps.distribution-commit.outputs.commit-hash }} + VERSION: ${{ needs.prepare-release-candidate.outputs.version }} 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 "$COMMIT_SHA" --title "$NEW_VERSION" --notes "$CHANGELOG_ENTRY" + set -euo pipefail + test -n "$COMMIT" + test "$(gh api "repos/$REPOSITORY/commits/$COMMIT" --jq '.commit.verification.verified')" = true + gh release create "v$VERSION" --repo "$REPOSITORY" --target "$COMMIT" --title "v$VERSION" --notes-file "$RUNNER_TEMP/candidate/notes.md" + - name: Publish independent source release and archive + env: + GH_TOKEN: ${{ secrets.LARAVEL_SOURCE_TOKEN }} + REPOSITORY: ${{ github.repository }} + COMMIT: ${{ needs.release.outputs.commit-hash }} + VERSION: ${{ needs.prepare-release-candidate.outputs.version }} + DISTRIBUTION_REPOSITORY: ${{ vars.LARAVEL_DISTRIBUTION_REPOSITORY }} + DISTRIBUTION_COMMIT: ${{ steps.distribution-commit.outputs.commit-hash }} + ARCHIVE_SHA256: ${{ needs.prepare-release-candidate.outputs.archive-sha256 }} + run: | + set -euo pipefail + printf '\nDistribution: %s@%s\nArchive SHA256: %s\n' "$DISTRIBUTION_REPOSITORY" "$DISTRIBUTION_COMMIT" "$ARCHIVE_SHA256" >> "$RUNNER_TEMP/candidate/notes.md" + gh release create "laravel-v$VERSION" --latest=false --repo "$REPOSITORY" --target "$COMMIT" --title "Laravel v$VERSION" --notes-file "$RUNNER_TEMP/candidate/notes.md" "$RUNNER_TEMP/candidate/package.tar.gz" "$RUNNER_TEMP/candidate/candidate.json" notify-released: name: Notify Slack - Released - needs: [notify-approval-needed, release] + needs: [notify-approval-needed, release, publish-laravel] runs-on: ubuntu-latest - if: always() && needs.release.result == 'success' && needs.notify-approval-needed.outputs.slack_ts != '' + if: always() && needs.release.result == 'success' && needs.publish-laravel.result == 'success' && needs.notify-approval-needed.outputs.slack_ts != '' steps: - name: Notify Slack - Released uses: posthog/.github/.github/actions/slack-thread-reply@cb0979b67dcd585828b61ac45927eef4da8f6287 # main @@ -452,14 +455,14 @@ jobs: 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: "🚀 posthog-php released successfully!" + message: "🚀 Updated PHP/Laravel packages released successfully!" emoji_reaction: "rocket" notify-release-failed: name: Notify Slack/PostHog - Release Failed - needs: [prepare-release-candidate, notify-approval-needed, release] + needs: [prepare-release-candidate, notify-approval-needed, release, publish-laravel] runs-on: ubuntu-latest - if: always() && (needs.release.result == 'failure' || needs.release.result == 'cancelled') && needs.notify-approval-needed.outputs.slack_ts != '' + if: always() && (needs.release.result == 'failure' || needs.release.result == 'cancelled' || needs.publish-laravel.result == 'failure' || needs.publish-laravel.result == 'cancelled') && needs.notify-approval-needed.outputs.slack_ts != '' permissions: actions: read contents: read @@ -497,15 +500,15 @@ jobs: properties: >- { "commitSha": "${{ github.sha }}", - "jobStatus": "${{ needs.release.result }}", + "jobStatus": "${{ needs.release.result == 'success' && needs.publish-laravel.result || needs.release.result }}", "ref": "${{ github.ref }}", "repository": "${{ github.repository }}", "sdk": "posthog-php", "jobName": "release", - "packageName": "posthog-php", - "packageVersion": "${{ needs.prepare-release-candidate.outputs.new-version || 'unknown' }}", + "packageName": "posthog-php,posthog-laravel", + "packageVersion": "posthog-php@${{ needs.prepare-release-candidate.outputs.core-version }},posthog-laravel@${{ needs.prepare-release-candidate.outputs.version }}", "actionUrl": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}", - "alertText": ":rotating_light: Failed to release posthog-php@${{ needs.prepare-release-candidate.outputs.new-version }} :rotating_light:", + "alertText": ":rotating_light: Failed to release PHP/Laravel packages :rotating_light:", "alertContext": "The release workflow failed before completion." } @@ -516,7 +519,7 @@ jobs: 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! " + message: "❌ PHP/Laravel release failed or was cancelled! " emoji_reaction: "x" - name: Notify Slack - Rejected diff --git a/.gitignore b/.gitignore index 56f1976..d456527 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,3 @@ xdebug.log .DS_Store .env node_modules/ -/.laravel-changes/ diff --git a/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json b/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json deleted file mode 100644 index 0721ba9..0000000 --- a/.laravel-changes/eb4dfed4373d440294ff58970818d3e7.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "package": "posthog-laravel", - "bump": "initial", - "summary": "Add opt-in Laravel request, auth, domain, model and worker event collectors." -} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cff303c..244e654 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,9 +43,38 @@ python3 -m unittest discover -s sdk_compliance_adapter -v After the Composer install above, run PHP_CodeSniffer with the Composer-installed tool and `phpcs.xml`, matching CI: ```bash -./vendor/bin/phpcs --standard=phpcs.xml --extensions=php . +./vendor/bin/phpcs --standard=phpcs.xml --extensions=php lib test ``` +## Laravel development + +`packages/laravel` is an independent Composer package and a pnpm workspace project +for release metadata. Both packages record change intents in `.changeset/`. +Its CI covers Laravel 12 on PHP 8.2–8.5 and Laravel 13 on PHP 8.3–8.5. +From a committed checkout, run: + +```bash +bash scripts/test-laravel.sh 12 +bash scripts/test-laravel.sh 13 +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -v +``` + +The framework checks lint tracked package/example PHP files, then install and test in +an automatically cleaned temporary source copy. They inject local Composer path +repositories for the checked-out core and Laravel package there, never into your +checkout or distribution metadata. The example's `composer test` runs when its +`composer.json` is available; a missing test command then fails rather than skips. +The release fixtures use pnpm 11.13.1 and fake publishing tools; they never publish +or use credentials. Root PHPCS checks remain scoped to `lib` and `test`. + +Record Laravel release changes with `pnpm change`, selecting `posthog-laravel`. +For core-only changes, select `posthog-php`: pnpm automatically adds a Laravel +patch to update its exact Composer core dependency. Select both when both packages +have code changes. One workspace version plan is committed before the packages +are published separately, core first. +See [the Laravel release guide](RELEASING.md#laravel-package-independent-releases) +for dependency-aware releases and required administrator setup. + ## Public API changes Public API is hard to change once it ships, so agree on it before writing the implementation. Our [SDK guidelines](https://posthog.com/handbook/engineering/sdks/guidelines) explain how we design it. diff --git a/RELEASING.md b/RELEASING.md index 173978a..54e11ef 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,97 +1,136 @@ # Releasing -This repository uses [pnpm's native release management](https://pnpm.io/versioning) and an automated GitHub Actions workflow for releases. +The PHP SDK and Laravel integration use [pnpm's native release management](https://pnpm.io/versioning), shared `.changeset/` intents, and one `.github/workflows/release.yml`. Like Ruby/Rails, they have independent versions but release according to their dependency direction. -## How to Release - -### 1. Record a Change Intent - -When making a change that should be released, record a change intent: +## Record a change ```bash pnpm change ``` -Select `posthog-php`, choose the version bump (`patch`, `minor`, or `major`), and enter a short release summary. Commit the generated `.changeset/*.md` file with your pull request. +Select the affected packages, bump types (`patch`, `minor`, or `major`), and a release summary. Commit the generated `.changeset/*.md` with the change. + +- **Core PHP change:** select `posthog-php`. Laravel declares `"posthog-php": "workspace:*"` in its release metadata, so pnpm automatically includes a Laravel patch release to update its exact core SDK pin. +- **Laravel-only change:** select `posthog-laravel`. Core is neither bumped nor republished. +- **Both packages changed:** select both with the appropriate bump types. pnpm combines explicit bumps and dependency propagation in one plan; Laravel is not bumped twice. + +An automatically generated Laravel patch is appropriate for a backwards-compatible core update. An incompatible core change also needs the corresponding Laravel code changes and an explicit appropriate bump. -### 2. Merge the Pull Request +Both release-metadata packages are private, so pnpm applies pending bumps without consulting npm. Laravel starts at **0.0.0**, and its initial **minor** intent produces **0.1.0**. Preparation rejects an initial 0.0.x release, including a dependency-only patch from a core change; add an explicit Laravel minor or major intent first. There is no custom initial-release bump or separate intent format. -After review, merge the PR to `main`. No GitHub release label is required. +## Dependency and version policy -A push to `main` that includes `.changeset/*.md` changes automatically starts the release workflow. The workflow then: +Laravel's Composer manifest pins `posthog/posthog-php` to the **exact core version** recorded in the same release commit, not a caret range. For example, a release built against PHP `4.20.1` requires exactly `4.20.1`. -1. Checks for pending change intents. A run whose trigger commit is no longer the tip of `main` skips itself if no releasable intents remain there, and fails if some do, since a later push that didn't touch `.changeset/` starts no release run of its own. In that case, dispatch the Release workflow on `main` -2. Uses `pnpm version -r` to determine and apply the version bump, consume the intents, update `CHANGELOG.md` and `.changeset/ledger.yaml`, and refresh `composer.lock` -3. Prepares a release candidate patch for the triggering commit in a read-only job without release secrets, after verifying the release bump script hash -4. Verifies the release candidate in a separate read-only job and fails if the tag or GitHub Release already exists -5. Notifies the client libraries team in Slack for approval only after candidate preparation and verification both succeed -6. Waits for one approval from a maintainer via the GitHub `Release` environment -7. Applies the verified release candidate patch, creates a signed release commit, tags the release, and creates a GitHub Release -8. Notifies Slack and records PostHog failure events from separate follow-up jobs, outside the approved publishing job +Laravel metadata validation uses Composer's `--no-check-all` to suppress its advisory against exact dependency constraints. Schema/publishing validation remains strict; the release validator separately enforces that the core dependency is the exact planned version. -### Manual Trigger +Release preparation runs **one unfiltered `pnpm version -r`** for the whole workspace. It then synchronizes: -You can also manually trigger the release workflow from the Actions tab with `workflow_dispatch`. Manual runs still require at least one pending, releasable change intent. +- Core `package.json`, `composer.json`, `lib/PostHog.php::VERSION`, `composer.lock`, and `CHANGELOG.md` when core changes. +- Laravel `package.json`, `composer.json`, `src/PostHog.php::VERSION`, and `CHANGELOG.md`, including its exact Composer core dependency. +- Shared `.changeset/ledger.yaml` and consumed intent files. -## Version Bumping +Do not version the packages separately with filters: pnpm can propagate a core bump into its dependent even with a filter, and separate plans can double-bump Laravel. One signed source commit contains the complete plan; publishing is package-specific and ordered core first. -The pending change intents determine the next version: +## Automated release flow -- **patch**: bug fixes, documentation updates, and internal changes -- **minor**: backwards-compatible features -- **major**: breaking changes +Merging `.changeset/*.md` changes into `main` starts **Release**. Manual dispatch on `main` uses the same flow and requires pending intents. -When multiple intents are pending, the workflow uses the largest requested bump. +1. A read-only job checks the complete dependency-aware plan. A stale trigger skips only if current `main` has no pending releases; otherwise it fails and asks for a new dispatch on current `main`. +2. Read-only preparation clones the exact committed source, applies the single pnpm plan, verifies the core bump-script hash before executing it, and refreshes the core Composer lock only when core changes. It produces a shared version patch, package-specific notes, a deterministic Laravel archive, and a digest manifest. Local modifications, vendors, and secrets never enter the candidate. +3. A separate read-only job independently reproduces all candidate bytes, checks planned source/distribution refs are unused, validates both versions and the exact dependency pin, and tests Laravel 12/13 against the patched candidate source. Distribution dependency resolution uses the exact candidate core via a temporary path repository because a new core version is not published yet. The archive itself never contains that override. +4. Slack approval notification precedes the protected **Release** environment. After approval, publishing rechecks source freshness, refs, and artifact digests, then creates **one atomic GitHub-signed source version commit** with `expectedHeadOid`. The existing PHP releaser GitHub App performs this commit. No Composer installs, package scripts, or git hooks run with that App token. +5. Core gets a GitHub release only if its version changed. Existing numeric PHP tags remain unchanged, for example `4.20.2`. Laravel-only releases do not republish core. +6. Laravel publication runs afterward in the protected **Laravel Release** environment. Before publishing, it waits up to approximately ten minutes for the exact core version on Packagist and installs the unmodified distribution without development dependencies, scripts, plugins, or path overrides. These Composer steps receive no publishing tokens. If indexing or dependency resolution fails, Laravel is not published. +7. Laravel's dedicated distribution repository receives an atomic GitHub-signed package-only commit and **`vX.Y.Z`** release. Source gets **`laravel-vX.Y.Z`** against the same shared version commit used by the core release, with archive and manifest attachments. Its source release is not marked latest, preserving the core latest-release pointer. +8. Slack success/failure notifications run after publication. An unrelated advance of `main` fails freshness checks rather than publishing stale source. ## Composer distribution archives -The root `.gitattributes` excludes `packages/laravel/` from the core Composer distribution. Laravel source stays in Git and has its own publishing flow. Tools that need the complete repository must use a Git checkout, not the core distribution archive. In particular, a whole-repository `git archive` no longer includes Laravel source or tests. +The root `.gitattributes` excludes `packages/laravel/` from the core Composer distribution. Tools needing the full source must use a Git checkout, not a whole-repository `git archive`. + +Packagist reads `composer.json` at a repository root, so Laravel publishes through a separate public **distribution repository**, recommended `PostHog/posthog-laravel`. Source remains in `packages/laravel`. + +The distribution contains package `composer.json`, README, license, changelog, autocapture/Pennant guides, PHP source/config, and generated `posthog-source.json`. Tests, vendors, hidden files, locks, local configuration, and secrets are excluded. Obsolete files in the dedicated distribution repository are removed. + +`packages/laravel/LICENSE.md` links to `../../LICENSE.md` in Git. Preparation embeds the regular root license contents in the archive. Other symlinks, alternate license targets, and a symlinked root license fail preparation. -## Troubleshooting +## Laravel package: independent releases -### No releasable change intents found +The packages do not share version numbers: a core patch can produce PHP `4.20.2` and Laravel `0.1.1`, with Laravel pinned to PHP `4.20.2`. A later Laravel-only patch can produce Laravel `0.1.2` while retaining that exact PHP pin. -If the release workflow reports that no releasable change intents were found, make sure your PR includes at least one `.changeset/*.md` file generated by `pnpm change` with a `patch`, `minor`, or `major` bump. +A change intent for Laravel alone can be created non-interactively: -### Updating the release bump script +```bash +pnpm change posthog-laravel --bump patch --summary "Describe the Laravel change" +``` -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: +The initial release is intentionally deferred until administrator setup is ready. To release 0.1.0 from the 0.0.0 seed, add a minor intent then: ```bash -sha256sum scripts/bump-version.sh +pnpm change posthog-laravel --bump minor --summary "Initial Laravel package" ``` -### Manual recovery after a failed release +### Administrator prerequisites -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. +These actions are **not** performed by release tooling or tests. Configure them before enabling publication: core changes now also plan a Laravel release. -If the signed release commit was created but `Create GitHub release` failed, prefer completing the release manually instead of rolling back: +1. Create a dedicated public distribution repository and initialize its `main` with a signed bootstrap commit. Set `LARAVEL_DISTRIBUTION_REPOSITORY` to its `owner/repository`; it must differ from the source repository. +2. Protect both **Release** and **Laravel Release** environments with maintainer approval, no self-review, and protected-main-only deployment rules. Keep normal PR/branch protections. Naming an environment in YAML does not configure these rules. +3. Keep the existing PHP releaser App credentials in **Release**. Its Contents write access must support GitHub's `createCommitOnBranch` API and verified GitHub-signed commits without disabling signature requirements. +4. Store environment-only `LARAVEL_SOURCE_TOKEN` and `LARAVEL_DISTRIBUTION_TOKEN` in **Laravel Release**, scoped narrowly to the respective repositories. Source needs release/tag access; distribution needs Contents write access including signed `createCommitOnBranch` requests and releases. Do not expose these tokens to preparation, verification, or Composer steps. +5. Register `posthog/posthog-laravel` on Packagist using the distribution repository and configure its normal GitHub update integration. Check ownership and signed-commit permissions. No repository, Packagist registration, webhook, credential, or approval rule is created by this workflow. +6. Land the separately documented server-side `posthog-laravel` library registration before shipping the integration. -```bash -VERSION= -COMMIT_SHA= +Missing configuration fails closed. Distribution preparation/verification must be able to read the public repository without publishing credentials. -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" -``` +## Preview and test -If the wrong GitHub Release or tag was created, delete both before retrying: +From a committed checkout, choose a candidate output path that does not already exist: ```bash -VERSION= +python3 scripts/release.py prepare \ + --repository PostHog/posthog-php --output /tmp/sdk-candidate \ + --bump-script-sha256 "$(shasum -a 256 scripts/bump-version.sh | awk '{print $1}')" +``` -gh release delete "$VERSION" --yes --cleanup-tag || true -git push origin ":refs/tags/$VERSION" || true +This only prepares local artifacts; it does not publish. The workflow pins the reviewed bump-script hash independently rather than deriving trust from the checked-out script. + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s scripts/tests -v +bash scripts/test-laravel.sh 12 +bash scripts/test-laravel.sh 13 +# Test a prepared version patch in the disposable source copy: +bash scripts/test-laravel.sh 12 /tmp/sdk-candidate/release.patch +actionlint .github/workflows/laravel.yml .github/workflows/release.yml ``` -If the signed version bump commit itself is wrong and must be undone, revert it with a new commit rather than force-pushing `main`: +Fixtures use pnpm 11.13.1, disposable repositories, fake Composer/gh clients, and no publishing credentials. They cover core-only automatic Laravel patching, Laravel-only core preservation, joint changes, exact pins, single-consumption ledgers, signed commit requests, deterministic archives, and secret/path/symlink exclusion. + +## Troubleshooting and recovery + +### No pending change intents + +Ensure `.changeset/*.md` contains at least one `patch`, `minor`, or `major` intent that pnpm has not already consumed. Do not restore a consumed intent merely to retry publishing: it can generate a second version bump. + +### Updating the core bump script + +`scripts/bump-version.sh` is hash-pinned in `release.yml`. If it changes, recompute its SHA256 and update `EXPECTED_BUMP_SCRIPT_SHA256` in the same reviewed PR. Preparation rejects a mismatch before executing the script. + +### Partial publication + +One source version commit and publishing to two repositories cannot be transactional. A failure can leave committed versions without tags, core published but Laravel unpublished, or a distribution tag without its source release. + +A retry of `publish-laravel` can reuse an unchanged distribution commit when both Laravel release refs are still unused. Reuse requires the complete committed file tree to match the approved archive byte-for-byte and GitHub to verify the commit signature; it never creates a replacement commit merely because the previous signature lookup or release request failed. An already-created tag/release still requires inspecting the existing publication rather than blindly retrying. + +Inspect the retained candidate artifacts, signatures, checksums, shared source commit, and existing tags/releases before recovery. Complete only the missing publication at the **same recorded version**, using the original artifacts and commit. In particular, wait for Packagist indexing rather than creating another release intent. An all-workflow rerun after versions were consumed does not create a new candidate automatically. + +For a missing core GitHub release, after verifying the source commit and notes: ```bash -git switch main -git pull --ff-only -RELEASE_COMMIT= -git revert "$RELEASE_COMMIT" -git push origin main +VERSION= +COMMIT_SHA= +gh release create "$VERSION" --target "$COMMIT_SHA" --title "$VERSION" --notes-file /path/to/candidate/core-notes.md ``` -After rollback, add or restore the needed change intent and let the release workflow prepare a new release candidate. +Never move an already published tag, overwrite distribution history, or rewrite source commits. Correct bad published versions with signed follow-up commits and a new intent/version. If an incorrect version commit must be undone, use a new signed `git revert`, not a force-push. Do not delete an already distributed version to make a retry look successful. diff --git a/examples/laravel/README.md b/examples/laravel/README.md index 8a4ce56..2f3c317 100644 --- a/examples/laravel/README.md +++ b/examples/laravel/README.md @@ -32,8 +32,9 @@ composer test ``` The example's **development-only path repositories** point to `../..` (PHP SDK, -version override `4.20.x-dev`) and `../../packages/laravel` (integration, override -`0.1.0`). Run inside this repository, not as an isolated copy. They do not belong +using its exact Composer manifest version) and `../../packages/laravel` +(integration, override `0.1.0` for the initial development version). Run inside +this repository, not as an isolated copy. They do not belong in a published package manifest. The lock does not replace the local source trees. ## Safe defaults and configuration diff --git a/examples/laravel/composer.json b/examples/laravel/composer.json index 9c83454..80024c9 100644 --- a/examples/laravel/composer.json +++ b/examples/laravel/composer.json @@ -19,10 +19,7 @@ "type": "path", "url": "../..", "options": { - "symlink": true, - "versions": { - "posthog/posthog-php": "4.20.x-dev" - } + "symlink": true } }, { diff --git a/examples/laravel/composer.lock b/examples/laravel/composer.lock index 61c15ae..febbebe 100644 --- a/examples/laravel/composer.lock +++ b/examples/laravel/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "42c36e7ac60dda86bb93f8d5b7218ae9", + "content-hash": "febc3526787d8705f7c9d2aceb49eb1d", "packages": [ { "name": "brick/math", @@ -2576,13 +2576,13 @@ "dist": { "type": "path", "url": "../../packages/laravel", - "reference": "b1adcaa487824e32426301063f5c26dcba793826" + "reference": "e13e8b41110059e81ffcea7d7f316924a340c925" }, "require": { "illuminate/contracts": "^12.0 || ^13.0", "illuminate/support": "^12.0 || ^13.0", "php": ">=8.2", - "posthog/posthog-php": "^4.20" + "posthog/posthog-php": "4.20.1" }, "require-dev": { "laravel/octane": "^2.0", @@ -2630,11 +2630,11 @@ }, { "name": "posthog/posthog-php", - "version": "4.20.x-dev", + "version": "4.20.1", "dist": { "type": "path", "url": "../..", - "reference": "4fdbc7bedc7cc4031da860373c591e5799cc09fb" + "reference": "227094c0d4221d6102ab114040f701e60636cf2c" }, "require": { "ext-json": "*", @@ -2671,6 +2671,12 @@ ], "api:update": [ "@php scripts/check-public-api.php --update" + ], + "api:check:laravel": [ + "@php scripts/check-public-api.php --laravel" + ], + "api:update:laravel": [ + "@php scripts/check-public-api.php --laravel --update" ] }, "license": [ diff --git a/packages/laravel/composer.json b/packages/laravel/composer.json index 4491616..06fd7f2 100644 --- a/packages/laravel/composer.json +++ b/packages/laravel/composer.json @@ -1,11 +1,11 @@ { "name": "posthog/posthog-laravel", "description": "PostHog for Laravel", - "version": "0.1.0", + "version": "0.0.0", "license": "MIT", "require": { "php": ">=8.2", - "posthog/posthog-php": "^4.20", + "posthog/posthog-php": "4.20.1", "illuminate/support": "^12.0 || ^13.0", "illuminate/contracts": "^12.0 || ^13.0" }, diff --git a/packages/laravel/package.json b/packages/laravel/package.json new file mode 100644 index 0000000..0caebfe --- /dev/null +++ b/packages/laravel/package.json @@ -0,0 +1,9 @@ +{ + "name": "posthog-laravel", + "version": "0.0.0", + "private": true, + "dependencies": { + "posthog-php": "workspace:*" + }, + "description": "Independent release metadata for posthog/posthog-laravel" +} diff --git a/packages/laravel/src/PostHog.php b/packages/laravel/src/PostHog.php index b9b9480..0887962 100644 --- a/packages/laravel/src/PostHog.php +++ b/packages/laravel/src/PostHog.php @@ -12,7 +12,7 @@ class PostHog { - public const VERSION = '0.1.0'; + public const VERSION = '0.0.0'; private ?Client $client = null; private ?int $definitionsLoadedAt = null; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b60ae1..a275081 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,3 +7,9 @@ settings: importers: .: {} + + packages/laravel: + dependencies: + posthog-php: + specifier: workspace:* + version: link:../.. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9b963ae..c0258fa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - '.' + - 'packages/laravel' minimumReleaseAge: 10080 diff --git a/scripts/check-release-changes.sh b/scripts/check-release-changes.sh new file mode 100644 index 0000000..26eaa76 --- /dev/null +++ b/scripts/check-release-changes.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +MAIN_SHA=$(git rev-parse --verify origin/main) +if [ "$MAIN_SHA" != "$TRIGGER_SHA" ]; then + git checkout --quiet --detach "$MAIN_SHA" +fi + +versions() { + python3 - <<'PY' +import json +for path in ['package.json', 'packages/laravel/package.json']: + print(json.load(open(path))['version']) +PY +} + +BEFORE=$(versions) +pnpm version -r +AFTER=$(versions) + +if [ "$BEFORE" = "$AFTER" ]; then + echo 'has-changesets=false' >> "$GITHUB_OUTPUT" + echo 'No pending change intents' +elif [ "$MAIN_SHA" != "$TRIGGER_SHA" ]; then + echo "main moved from $TRIGGER_SHA to $MAIN_SHA with pending changes; dispatch Release on main" >&2 + exit 1 +else + echo 'has-changesets=true' >> "$GITHUB_OUTPUT" + echo "Pending release versions: $AFTER" +fi diff --git a/scripts/check-release-refs.sh b/scripts/check-release-refs.sh new file mode 100644 index 0000000..b762f69 --- /dev/null +++ b/scripts/check-release-refs.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${SOURCE_REPOSITORY:?Source repository is required}" +: "${DISTRIBUTION_REPOSITORY:?Configure LARAVEL_DISTRIBUTION_REPOSITORY first}" +: "${VERSION:?Version is required}" +[[ "$SOURCE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] +[[ "$DISTRIBUTION_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +[ "$(printf %s "$SOURCE_REPOSITORY" | tr '[:upper:]' '[:lower:]')" != "$(printf %s "$DISTRIBUTION_REPOSITORY" | tr '[:upper:]' '[:lower:]')" ] + +# Only an explicit HTTP 404 means absent. Permissions, rate limits and outages fail closed. +missing() { + local endpoint=$1 output + if output=$(gh api "$endpoint" 2>&1); then + echo "Release ref already exists: $endpoint" >&2 + exit 1 + fi + if ! grep -q 'HTTP 404' <<< "$output"; then + echo "Could not check release ref: $output" >&2 + exit 1 + fi +} + +# The distribution must already be a public, dedicated repository with a main branch. +[ "$(gh api "repos/$DISTRIBUTION_REPOSITORY" --jq '.private')" = false ] +gh api "repos/$DISTRIBUTION_REPOSITORY/git/ref/heads/main" --jq '.object.sha' > /dev/null +if [ "${CORE_CHANGED:-false}" = true ]; then + [[ "${CORE_VERSION:?Core version is required}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +fi +for endpoint in releases/tags git/ref/tags; do + if [ "${CORE_CHANGED:-false}" = true ]; then + missing "repos/$SOURCE_REPOSITORY/$endpoint/$CORE_VERSION" + fi + missing "repos/$SOURCE_REPOSITORY/$endpoint/laravel-v$VERSION" + missing "repos/$DISTRIBUTION_REPOSITORY/$endpoint/v$VERSION" +done diff --git a/scripts/commit_release.py b/scripts/commit_release.py new file mode 100644 index 0000000..27b9f1e --- /dev/null +++ b/scripts/commit_release.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Create a GitHub-signed commit, atomically rejecting a moved destination branch. + +Only called by the environment-approved workflow. Does not run Composer, package +hooks, git hooks, or shell code. Authentication is supplied to gh by the environment. +""" + +import argparse +import base64 +import json +from pathlib import Path +import re +import subprocess +import sys + +sys.dont_write_bytecode = True +from release import allowed_distribution_file, distribution_files + +SOURCE_FILES = { + "package.json", "composer.json", "composer.lock", "lib/PostHog.php", "CHANGELOG.md", + "packages/laravel/package.json", "packages/laravel/composer.json", + "packages/laravel/src/PostHog.php", "packages/laravel/CHANGELOG.md", ".changeset/ledger.yaml", +} +QUERY = """ +mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { commit { oid } } +} +""" + + +def request(checkout, repository, mode, message): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("Invalid repository") + def git(*args): + return subprocess.check_output(["git", *args], cwd=checkout) + expected = git("rev-parse", "HEAD").decode().strip() + # Include untracked distribution files, with no hooks or identity/signing configuration. + paths = set(filter(None, git("diff", "--name-only", "--no-renames", "-z", "HEAD").decode().split("\0"))) + paths.update(filter(None, git("ls-files", "--others", "--exclude-standard", "-z").decode().split("\0"))) + additions, deletions = [], [] + for name in sorted(paths): + permitted = (name in SOURCE_FILES or bool(re.fullmatch(r"\.changeset/[A-Za-z0-9_-]+\.md", name))) if mode == "source" else ( + allowed_distribution_file(name) or name == "posthog-source.json" + ) + path = checkout / name + # Any obsolete tracked distribution file may be deleted, but only allowlisted + # package files may be added. Source changes always use the narrow allowlist. + if not permitted and (mode == "source" or path.exists() or path.is_symlink()): + raise ValueError(f"Unexpected {mode} release change: {name}") + if path.is_symlink(): + raise ValueError(f"Symlink in release: {name}") + if path.exists(): + additions.append({"path": name, "contents": base64.b64encode(path.read_bytes()).decode()}) + else: + deletions.append({"path": name}) + if not paths: + if mode == "distribution": + return None + raise ValueError("No release changes") + return {"query": QUERY, "variables": {"input": { + "branch": {"repositoryNameWithOwner": repository, "branchName": "main"}, + "expectedHeadOid": expected, + "message": {"headline": message}, + "fileChanges": {"additions": additions, "deletions": deletions}, + }}} + + +def matching_distribution_commit(checkout, repository, candidate): + files = distribution_files(candidate) + def git(*args): + return subprocess.check_output(["git", *args], cwd=checkout) + head = git("rev-parse", "HEAD").decode().strip() + committed = {} + for entry in filter(None, git("ls-tree", "-r", "-z", head).split(b"\0")): + metadata, name = entry.split(b"\t", 1) + if not metadata.startswith(b"100644 blob "): + raise ValueError("Distribution HEAD does not match the approved candidate") + name = name.decode() + committed[name] = git("show", f"{head}:{name}") + if committed != files: + raise ValueError("Distribution HEAD does not match the approved candidate") + verified = subprocess.check_output([ + "gh", "api", f"repos/{repository}/commits/{head}", "--jq", ".commit.verification.verified", + ]).decode().strip() + if verified != "true": + raise ValueError("Matching distribution commit must have a verified signature") + return head + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkout", type=Path, required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--mode", choices=["source", "distribution"], required=True) + parser.add_argument("--message", required=True) + parser.add_argument("--candidate", type=Path) + args = parser.parse_args() + payload = request(args.checkout, args.repository, args.mode, args.message) + if payload is None: + if args.candidate is None: + raise ValueError("Distribution retry requires the approved candidate") + commit = matching_distribution_commit(args.checkout, args.repository, args.candidate) + else: + result = subprocess.check_output(["gh", "api", "graphql", "--input", "-"], input=json.dumps(payload).encode()) + response = json.loads(result) + if response.get("errors"): + raise RuntimeError(response["errors"]) + commit = response["data"]["createCommitOnBranch"]["commit"]["oid"] + if not re.fullmatch(r"[a-f0-9]{40}", commit): + raise ValueError("Invalid commit returned by GitHub") + print(f"commit-hash={commit}") + + +if __name__ == "__main__": + main() diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..a809966 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Prepare one dependency-aware PHP/Laravel release candidate without publishing credentials.""" + +import argparse +import gzip +import hashlib +import io +import json +from pathlib import Path, PurePosixPath +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile + +PACKAGE = Path("packages/laravel") +CHANGELOG = PACKAGE / "CHANGELOG.md" +FORBIDDEN = {"vendor", "tests", "test", "node_modules", "storage", "cache", "secrets"} + + +def run(*args, cwd): + try: + return subprocess.check_output(args, cwd=cwd, stderr=subprocess.PIPE) + except subprocess.CalledProcessError as error: + if error.output: + print(error.output.decode(errors="replace"), end="", file=sys.stdout, flush=True) + if error.stderr: + print(error.stderr.decode(errors="replace"), end="", file=sys.stderr, flush=True) + raise + + +def read_json(path): + return json.loads(path.read_text()) + + +def write_json(path, value): + path.write_text(json.dumps(value, indent=2) + "\n") + + +def allowed_distribution_file(name): + path = PurePosixPath(name) + if path.is_absolute() or any(p.startswith(".") or p.lower() in FORBIDDEN for p in path.parts): + return False + return name in {"composer.json", "README.md", "LICENSE.md", "CHANGELOG.md", "AUTOCAPTURE.md", "PENNANT.md"} or ( + len(path.parts) > 1 and path.parts[0] in {"src", "config"} and path.suffix == ".php" + ) + + +def archive(files): + """Fixed ordering, ownership, mode and timestamps make byte-for-byte verification possible.""" + output = io.BytesIO() + with gzip.GzipFile(fileobj=output, mode="wb", mtime=0, filename="") as zipped: + with tarfile.open(fileobj=zipped, mode="w", format=tarfile.USTAR_FORMAT) as tar: + for name, data in sorted(files.items()): + entry = tarfile.TarInfo(name) + entry.size = len(data) + entry.mode = 0o644 + tar.addfile(entry, io.BytesIO(data)) + return output.getvalue() + + +def prepare(root, output, repository, bump_script_sha256=None): + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("Expected owner/repository") + if output.exists(): + raise ValueError("Candidate output must not already exist") + source = run("git", "rev-parse", "HEAD", cwd=root).decode().strip() + # Only committed files enter the candidate; local vendors, .env and modifications never enter. + with tempfile.TemporaryDirectory(prefix="laravel-release-") as temporary: + work = Path(temporary) / "source" + run("git", "clone", "--quiet", "--no-hardlinks", "--no-local", str(root), str(work), cwd=root) + run("git", "checkout", "--quiet", "--detach", source, cwd=work) + pending = sorted((work / ".changeset").glob("*.md")) + metadata = read_json(work / PACKAGE / "package.json") + composer = read_json(work / PACKAGE / "composer.json") + current_core = read_json(work / "package.json")["version"] + current = metadata["version"] + if metadata.get("dependencies", {}).get("posthog-php") != "workspace:*": + raise ValueError("Laravel must declare its workspace dependency on posthog-php") + if composer["require"]["posthog/posthog-php"] != current_core: + raise ValueError("Laravel must pin the exact current core SDK version") + if composer.get("version", current) != current: + raise ValueError("Laravel package.json/composer.json version mismatch") + if composer["name"] != "posthog/posthog-laravel" or "repositories" in composer: + raise ValueError("Expected production Laravel Composer metadata (no repository overrides)") + runtime = work / PACKAGE / "src/PostHog.php" + declaration = re.compile(r"(public const VERSION = ')([^']+)(';)") + declarations = declaration.findall(runtime.read_text()) + if len(declarations) != 1 or declarations[0][1] != current: + raise ValueError("Laravel runtime VERSION mismatch") + core_runtime = work / "lib/PostHog.php" + if read_json(work / "composer.json")["version"] != current_core or ( + [match[1] for match in declaration.findall(core_runtime.read_text())] != [current_core] + ): + raise ValueError("Core SDK version mismatch") + run("pnpm", "version", "-r", cwd=work) + new = read_json(work / PACKAGE / "package.json")["version"] + core_version = read_json(work / "package.json")["version"] + core_changed = core_version != current_core + if new == current: + if core_changed: + raise ValueError("Core release must include its Laravel dependent") + raise ValueError("No pending change intents") + if current == "0.0.0" and new.split(".")[:2] == ["0", "0"]: + raise ValueError("Initial Laravel release requires a minor or major intent") + if core_changed: + script = work / "scripts/bump-version.sh" + if hashlib.sha256(script.read_bytes()).hexdigest() != bump_script_sha256: + raise ValueError("Core bump script sha256 mismatch") + run("bash", str(script), core_version, cwd=work) + run("composer", "update", "--lock", "--no-install", "--no-interaction", "--no-audit", "--no-scripts", "--no-plugins", cwd=work) + run("git", "add", "--intent-to-add", "CHANGELOG.md", cwd=work) + runtime.write_text(declaration.sub(lambda match: match[1] + new + match[3], runtime.read_text())) + composer["version"] = new + composer["require"]["posthog/posthog-php"] = core_version + write_json(work / PACKAGE / "composer.json", composer) + changelog = (work / CHANGELOG).read_text() + notes = changelog[changelog.index(f"## {new}\n"):].split("\n## ", 1)[0] + run("git", "add", "--intent-to-add", str(CHANGELOG), ".changeset/ledger.yaml", cwd=work) + tracked = run("git", "ls-files", "-z", "--", str(PACKAGE), cwd=work).decode().split("\0") + files = {} + for name in filter(None, tracked): + relative = Path(name).relative_to(PACKAGE).as_posix() + if not allowed_distribution_file(relative): + continue + path = work / name + if relative == "LICENSE.md" and path.is_symlink() and path.readlink() == Path("../../LICENSE.md"): + path = work / "LICENSE.md" + if path.is_symlink() or not path.is_file(): + raise ValueError(f"Not a regular distribution file: {name}") + files[relative] = path.read_bytes() + if not {"composer.json", "README.md", "LICENSE.md"}.issubset(files) or not any(n.startswith("src/") for n in files): + raise ValueError("Incomplete Laravel package") + tag = f"laravel-v{new}" + files["posthog-source.json"] = (json.dumps({ + "repository": repository, "commit": source, "tag": tag, + "directory": PACKAGE.as_posix(), "version": new, + }, indent=2) + "\n").encode() + patch = run("git", "diff", "--binary", cwd=work) + changed = run("git", "diff", "--name-only", cwd=work).decode().splitlines() + permitted = {str(PACKAGE / "composer.json"), str(PACKAGE / "package.json"), + str(PACKAGE / "src/PostHog.php"), str(CHANGELOG), ".changeset/ledger.yaml"} + if core_changed: + permitted.update({"package.json", "composer.json", "composer.lock", "lib/PostHog.php", "CHANGELOG.md"}) + permitted.update(str(path.relative_to(work)) for path in pending) + if not set(changed).issubset(permitted): + raise ValueError(f"Unexpected release changes: {changed}") + payload = archive(files) + output.mkdir(parents=True) + (output / "release.patch").write_bytes(patch) + (output / "package.tar.gz").write_bytes(payload) + (output / "notes.md").write_text(notes + f"Source: {repository}@{source} ({PACKAGE})\n") + core_notes = "" + if core_changed: + changelog = (work / "CHANGELOG.md").read_text() + core_notes = changelog[changelog.index(f"## {core_version}\n"):].split("\n## ", 1)[0] + (output / "core-notes.md").write_text(core_notes) + write_json(output / "candidate.json", { + "version": new, "core_version": core_version, "core_changed": core_changed, + "source": source, "source_tag": tag, "distribution_tag": f"v{new}", + "patch_sha256": hashlib.sha256(patch).hexdigest(), + "archive_sha256": hashlib.sha256(payload).hexdigest(), + }) + validate(work, output) + + +def validate(root, candidate): + manifest = read_json(candidate / "candidate.json") + for path, runtime, version in [ + (Path(), Path("lib/PostHog.php"), manifest["core_version"]), + (PACKAGE, PACKAGE / "src/PostHog.php", manifest["version"]), + ]: + composer = read_json(root / path / "composer.json") + declared = re.findall(r"public const VERSION = '([^']+)';", (root / runtime).read_text()) + if read_json(root / path / "package.json")["version"] != version or composer["version"] != version or declared != [version]: + raise ValueError(f"Version mismatch: {path}") + if read_json(root / PACKAGE / "composer.json")["require"]["posthog/posthog-php"] != manifest["core_version"]: + raise ValueError("Laravel core SDK pin mismatch") + + +def distribution_files(candidate): + manifest = read_json(candidate / "candidate.json") + payload = (candidate / "package.tar.gz").read_bytes() + if hashlib.sha256(payload).hexdigest() != manifest["archive_sha256"]: + raise ValueError("Distribution archive digest mismatch") + files = {} + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as tar: + for entry in tar: + if not entry.isfile() or entry.name in files or not ( + allowed_distribution_file(entry.name) or entry.name == "posthog-source.json" + ): + raise ValueError(f"Unexpected archive member: {entry.name}") + files[entry.name] = tar.extractfile(entry).read() + if json.loads(files["composer.json"])["version"] != manifest["version"]: + raise ValueError("Distribution version mismatch") + return files + + +def install(candidate, destination, repository): + """Replace a dedicated distribution checkout; never extract tar paths unchecked.""" + files = distribution_files(candidate) + if not (destination / ".git").exists(): + raise ValueError("Destination must be a dedicated distribution Git checkout") + source = json.loads(files["posthog-source.json"]) + if repository.lower() == source["repository"].lower() or not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("Distribution repository must be separate from source") + remote = run("git", "remote", "get-url", "origin", cwd=destination).decode().strip() + if remote not in {f"https://github.com/{repository}", f"https://github.com/{repository}.git"}: + raise ValueError("Distribution checkout origin does not match configured repository") + for path in destination.iterdir(): + if path.name == ".git": + continue + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + for name, data in files.items(): + path = destination / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + candidate = sub.add_parser("prepare") + candidate.add_argument("--root", type=Path, default=Path.cwd()) + candidate.add_argument("--output", type=Path, required=True) + candidate.add_argument("--repository", required=True) + candidate.add_argument("--bump-script-sha256") + validation = sub.add_parser("validate") + validation.add_argument("--root", type=Path, default=Path.cwd()) + validation.add_argument("--candidate", type=Path, required=True) + unpack = sub.add_parser("install") + unpack.add_argument("--candidate", type=Path, required=True) + unpack.add_argument("--destination", type=Path, required=True) + unpack.add_argument("--repository", required=True) + args = parser.parse_args() + if args.command == "prepare": + prepare(args.root.resolve(), args.output.resolve(), args.repository, args.bump_script_sha256) + elif args.command == "validate": + validate(args.root.resolve(), args.candidate.resolve()) + else: + install(args.candidate.resolve(), args.destination.resolve(), args.repository) + + +if __name__ == "__main__": + main() diff --git a/scripts/test-laravel.sh b/scripts/test-laravel.sh new file mode 100644 index 0000000..de62d76 --- /dev/null +++ b/scripts/test-laravel.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Composer overrides and generated locks live only in this disposable source copy. +LARAVEL=${1:?Usage: scripts/test-laravel.sh <12|13>} +case "$LARAVEL" in + 12) TESTBENCH=10 ;; + 13) TESTBENCH=11 ;; + *) echo 'Supported Laravel versions: 12, 13' >&2; exit 1 ;; +esac +ROOT=$(git rev-parse --show-toplevel) +TEMP=$(mktemp -d) +trap 'rm -rf "$TEMP"' EXIT +SOURCE=$(git -C "$ROOT" rev-parse HEAD) +git clone --quiet --no-hardlinks --no-local "$ROOT" "$TEMP" +git -C "$TEMP" checkout --quiet --detach "$SOURCE" +if [ -n "${2:-}" ]; then + git -C "$TEMP" apply --index "$2" +fi + +# Lint only tracked source, before Composer can populate dependency directories. +while IFS= read -r -d '' file; do + case "$file" in + *.php) php -l "$TEMP/$file" ;; + esac +done < <(git -C "$ROOT" ls-files -z -- packages/laravel examples/laravel) + +# Test the exact source pair without putting path repositories in distribution metadata. +python3 - "$TEMP" "$LARAVEL" <<'PY' +import json +from pathlib import Path +import sys +root = Path(sys.argv[1]) +core_version = json.loads((root / 'package.json').read_text())['version'] +laravel_version = json.loads((root / 'packages/laravel/package.json').read_text())['version'] +for relative in ['packages/laravel', 'examples/laravel']: + path = root / relative / 'composer.json' + if not path.exists(): + continue + data = json.loads(path.read_text()) + repositories = [{ + 'type': 'path', 'url': str(root), + 'options': {'symlink': False, 'versions': {'posthog/posthog-php': core_version}}, + }] + if relative.startswith('examples/'): + data['require']['posthog/posthog-laravel'] = laravel_version + repositories.append({ + 'type': 'path', 'url': str(root / 'packages/laravel'), + 'options': {'symlink': False, 'versions': {'posthog/posthog-laravel': laravel_version}}, + }) + data['repositories'] = repositories + if relative.startswith('examples/') and sys.argv[2] == '13': + data.setdefault('config', {}).setdefault('platform', {})['php'] = '8.3.0' + path.write_text(json.dumps(data, indent=2) + '\n') + path.with_name('composer.lock').unlink(missing_ok=True) +PY + +cd "$TEMP/packages/laravel" +composer validate --no-check-version --no-check-publish --no-check-all +composer require --dev --no-update --no-interaction "orchestra/testbench:^$TESTBENCH.0" "laravel/framework:^$LARAVEL.0" +composer update --prefer-dist --no-progress --no-interaction --no-scripts +./vendor/bin/phpunit --configuration phpunit.xml + +if [ -f "$TEMP/examples/laravel/composer.json" ]; then + cd "$TEMP/examples/laravel" + composer require --no-update --no-interaction "laravel/framework:^$LARAVEL.0" + composer update --prefer-dist --no-progress --no-interaction --no-scripts + # The example owns its executable test command; do not silently skip a missing command. + composer run test +else + echo 'Laravel example not yet present; package tests completed.' +fi diff --git a/scripts/tests/test_commit_laravel_release.py b/scripts/tests/test_commit_laravel_release.py new file mode 100644 index 0000000..5edab2a --- /dev/null +++ b/scripts/tests/test_commit_laravel_release.py @@ -0,0 +1,144 @@ +"""Test the credential-free GraphQL payload, not the live publishing endpoint.""" + +import base64 +import importlib.util +import json +import os +import subprocess +import sys +import textwrap + +from test_laravel_release import LaravelReleaseFixture, REPO, SCRIPT, git, release, snapshot + +sys.path.insert(0, str(SCRIPT.parent)) +spec = importlib.util.spec_from_file_location("commit_release", SCRIPT.with_name("commit_release.py")) +commits = importlib.util.module_from_spec(spec) +spec.loader.exec_module(commits) + + +class CommitLaravelReleaseTest(LaravelReleaseFixture): + def test_signed_commit_payload_has_atomic_expected_head_and_scoped_changes(self): + out = self.prepare() + expected = git(self.root, "rev-parse", "HEAD").decode().strip() + git(self.root, "apply", "--index", str(out / "release.patch")) + payload = commits.request(self.root, "PostHog/posthog-php", "source", "Release Laravel") + request = payload["variables"]["input"] + self.assertEqual(request["expectedHeadOid"], expected) + self.assertEqual(request["branch"], {"repositoryNameWithOwner": "PostHog/posthog-php", "branchName": "main"}) + self.assertIn("createCommitOnBranch", payload["query"]) + additions = {item["path"]: base64.b64decode(item["contents"]) for item in request["fileChanges"]["additions"]} + self.assertEqual(json.loads(additions["packages/laravel/package.json"])["version"], "0.1.1") + self.assertEqual(request["fileChanges"]["deletions"], [{"path": ".changeset/release.md"}]) + self.assertIn(".changeset/ledger.yaml", additions) + self.assertIn("packages/laravel/CHANGELOG.md", additions) + self.assertFalse(any(name in additions for name in ["composer.json", "package.json", "lib/PostHog.php"])) + self.write(".github/workflows/release.yml", "unexpected workflow modification") + with self.assertRaisesRegex(ValueError, "Unexpected source release change"): + commits.request(self.root, "PostHog/posthog-php", "source", "Release Laravel") + + def test_one_source_payload_contains_both_versions_and_the_updated_pin(self): + self.write(".changeset/core.md", '---\n"posthog-php": patch\n---\n\nCore change\n') + snapshot(self.root) + git(self.root, "apply", "--index", str(self.prepare() / "release.patch")) + payload = commits.request(self.root, "PostHog/posthog-php", "source", "Update package versions") + changes = payload["variables"]["input"]["fileChanges"] + additions = {item["path"]: base64.b64decode(item["contents"]) for item in changes["additions"]} + self.assertEqual(json.loads(additions["package.json"])["version"], "4.20.1") + laravel = json.loads(additions["packages/laravel/composer.json"]) + self.assertEqual(laravel["version"], "0.1.1") + self.assertEqual(laravel["require"]["posthog/posthog-php"], "4.20.1") + self.assertIn("composer.lock", additions) + self.assertIn("CHANGELOG.md", additions) + self.assertIn("packages/laravel/CHANGELOG.md", additions) + + def distribution(self): + candidate = self.prepare() + destination = self.directory / "laravel-distribution" + destination.mkdir() + git(destination, "init", "-q", "-b", "main") + git(destination, "remote", "add", "origin", "https://github.com/PostHog/posthog-laravel") + release.install(candidate, destination, "PostHog/posthog-laravel") + snapshot(destination) + release.install(candidate, destination, "PostHog/posthog-laravel") + return candidate, destination + + def retry_distribution(self, candidate, verified="true"): + gh = self.bin / "gh" + gh.write_text('''#!/usr/bin/env python3 +import json, os, sys +with open(os.environ['GH_TEST_LOG'], 'a') as log: + log.write(json.dumps(sys.argv[1:]) + '\\n') +if sys.argv[1] == 'api' and '/commits/' in sys.argv[2]: + print(os.environ['COMMIT_VERIFIED']) +else: + sys.exit('Unexpected API mutation on distribution retry') +''') + gh.chmod(0o755) + workflow = (REPO / ".github/workflows/release.yml").read_text() + step = workflow.split(' - name: Atomic GitHub-signed distribution commit\n', 1)[1].split(' - name: Publish distribution version\n', 1)[0] + script = textwrap.dedent(step.split(' run: |\n', 1)[1]) + output = self.directory / "outputs" + log = self.directory / "gh.log" + result = subprocess.run(['bash', '-c', script], cwd=REPO, env={ + **os.environ, 'PATH': f'{self.bin}:{os.environ["PATH"]}', + 'GITHUB_WORKSPACE': str(self.directory), 'RUNNER_TEMP': str(self.directory), + 'GITHUB_OUTPUT': str(output), 'GH_TEST_LOG': str(log), 'COMMIT_VERIFIED': verified, + 'REPOSITORY': 'PostHog/posthog-laravel', 'VERSION': '0.1.1', + 'SOURCE': release.read_json(candidate / 'candidate.json')['source'], + }, capture_output=True, text=True) + return result, output, log + + def test_distribution_retry_reuses_the_matching_verified_commit(self): + candidate, destination = self.distribution() + expected = git(destination, "rev-parse", "HEAD").decode().strip() + result, output, log = self.retry_distribution(candidate) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output.read_text(), f'commit-hash={expected}\n') + self.assertEqual(git(destination, 'rev-parse', 'HEAD').decode().strip(), expected) + requests = [json.loads(line) for line in log.read_text().splitlines()] + self.assertEqual(requests, [['api', f'repos/PostHog/posthog-laravel/commits/{expected}', '--jq', '.commit.verification.verified']]) + + def test_distribution_retry_rejects_an_unverified_matching_commit(self): + candidate, _ = self.distribution() + result, output, _ = self.retry_distribution(candidate, verified="false") + self.assertNotEqual(result.returncode, 0) + self.assertIn("verified signature", result.stderr) + self.assertEqual(output.read_text(), "") + + def test_distribution_retry_rejects_a_different_committed_tree(self): + candidate, destination = self.distribution() + (destination / "README.md").write_text("Different package content\n") + snapshot(destination) + result, output, log = self.retry_distribution(candidate) + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match the approved candidate", result.stderr) + self.assertEqual(output.read_text(), "") + self.assertFalse(log.exists()) + + def test_distribution_retry_detects_candidate_files_hidden_by_git_ignores(self): + candidate, destination = self.distribution() + (destination / "README.md").unlink() + snapshot(destination) + (destination / ".git/info/exclude").write_text("README.md\n") + release.install(candidate, destination, "PostHog/posthog-laravel") + self.assertEqual(git(destination, "status", "--porcelain"), b"") + result, output, log = self.retry_distribution(candidate) + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match the approved candidate", result.stderr) + self.assertEqual(output.read_text(), "") + self.assertFalse(log.exists()) + + def test_unchanged_source_commit_is_not_reused(self): + with self.assertRaisesRegex(ValueError, "No release changes"): + commits.request(self.root, "PostHog/posthog-php", "source", "Update versions") + + def test_distribution_payload_removes_obsolete_files_but_cannot_add_secrets(self): + # A standalone distribution repository may need to remove old development files. + self.write("old-secret.txt", "old development file") + snapshot(self.root) + (self.root / "old-secret.txt").unlink() + payload = commits.request(self.root, "PostHog/posthog-laravel", "distribution", "Release") + self.assertEqual(payload["variables"]["input"]["fileChanges"]["deletions"], [{"path": "old-secret.txt"}]) + self.write("auth.json", "new secret") + with self.assertRaisesRegex(ValueError, "Unexpected distribution release change"): + commits.request(self.root, "PostHog/posthog-laravel", "distribution", "Release") diff --git a/scripts/tests/test_laravel_release.py b/scripts/tests/test_laravel_release.py new file mode 100644 index 0000000..d8ce39b --- /dev/null +++ b/scripts/tests/test_laravel_release.py @@ -0,0 +1,321 @@ +"""Release boundaries, tested entirely in disposable repositories (no credentials).""" + +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import sys +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +SCRIPT = Path(__file__).resolve().parents[1] / "release.py" +REPO = SCRIPT.parents[1] +spec = importlib.util.spec_from_file_location("laravel_release", SCRIPT) +release = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release) + + +def git(root, *args, input=None): + return subprocess.check_output(["git", *args], cwd=root, input=input, stderr=subprocess.PIPE) + + +def snapshot(root): + """Create a synthetic fixture commit object, not a user commit or signing operation.""" + git(root, "add", ".") + tree = git(root, "write-tree").decode().strip() + parent = git(root, "rev-parse", "--verify", "HEAD").decode().strip() if (root / ".git/refs/heads/main").exists() else None + data = f"tree {tree}\n" + (f"parent {parent}\n" if parent else "") + data += "author Fixture 1700000000 +0000\ncommitter Fixture 1700000000 +0000\n\nFixture\n" + sha = git(root, "hash-object", "-t", "commit", "-w", "--stdin", input=data.encode()).decode().strip() + git(root, "update-ref", "refs/heads/main", sha) + + +class LaravelReleaseFixture(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) + self.root = self.directory / "source" + self.root.mkdir() + git(self.root, "init", "-q", "-b", "main") + self.write("package.json", json.dumps({"name": "posthog-php", "version": "4.20.0", "private": True, "packageManager": "pnpm@11.13.1"})) + self.write("pnpm-workspace.yaml", (REPO / "pnpm-workspace.yaml").read_text()) + self.write("composer.json", json.dumps({"name": "posthog/posthog-php", "version": "4.20.0"}, indent=2) + "\n") + self.write("composer.lock", '{"fixture_version":"4.20.0"}\n') + self.write("CHANGELOG.md", "# posthog-php\n\n## 4.20.0\n\nPrevious release\n") + self.write("scripts/bump-version.sh", (REPO / "scripts/bump-version.sh").read_text()) + self.write("lib/PostHog.php", "