diff --git a/.github/RELEASE_SETUP.md b/.github/RELEASE_SETUP.md index 48561b4..3bcd968 100644 --- a/.github/RELEASE_SETUP.md +++ b/.github/RELEASE_SETUP.md @@ -46,31 +46,11 @@ This guide explains how to set up automatic publishing to Packagist using GitHub ## How It Works -### Automatic Publishing - -When a PR is merged into `main` or `master`, the release workflow will: - -1. Parse the PR title using Conventional Commit style. - - Required ticket format: `type: [FEEDS-1234] description` - - Keep `feat`/`fix`/`bug` at the beginning of the title -2. Decide the bump type from the PR title (the body is not scanned): - - `feat:` => minor - - `fix:` or `bug:` => patch - - `feat!:` / `fix!:` / `(scope)!:` (the `!` marker) => major -3. Update `composer.json` and `src/Constant.php` via `scripts/release/bump_version.php` -4. Commit version files, create a `vX.Y.Z` tag, create a GitHub release -5. Trigger Packagist update - -### Creating a Release - -1. Open a PR with a Conventional Commit style title, for example: - - `feat: [FEEDS-1350] add feed search endpoint` - - `fix: [FEEDS-1402] handle nil reaction id` - - `feat!: [FEEDS-1410] remove deprecated batch API` -2. Merge the PR into `main` or `master`. -3. GitHub Actions will automatically perform release + Packagist update. - -Titles like `chore:`, `docs:`, `test:` do not trigger a release. +Releases are driven by [release-please](https://github.com/googleapis/release-please). +The Releases section of `README.md` is the reference; the short version is that merging +a PR with a conventional title updates a bot-authored Release PR, and merging that +Release PR runs the suite, creates the tag and the GitHub Release, and announces the tag +to Packagist. ### Manual Publishing (if needed) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac79f11..8419060 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,73 +1,33 @@ name: ci +# pull_request only. A push trigger alongside it ran the whole suite twice on the same +# SHA, and the duplicate integration leg raced the other run against the Stream app +# several SDK repos share. The trade is that a merge to master, and a direct push to it, +# now run nothing: release.yml runs this same workflow only when a release is pending, +# so the gate before a tag is covered but the routine post-merge signal is gone. on: pull_request: - push: - branches: [master, main] concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true permissions: - actions: write contents: read pull-requests: read jobs: check-pr-title: name: Validate PR title - if: github.event_name == 'pull_request' runs-on: ubuntu-latest permissions: statuses: write steps: - - uses: aslafy-z/conventional-pr-title-action@v3 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: aslafy-z/conventional-pr-title-action@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - test: - name: ๐Ÿงช Test & lint + tests: needs: check-pr-title - if: always() && (github.event_name != 'pull_request' || needs.check-pr-title.result == 'success') - environment: ci - runs-on: ubuntu-latest - - strategy: - matrix: - php-version: ['8.1', '8.2', '8.3'] - - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - extensions: curl, json - tools: composer:v2 - - - name: Cache composer dependencies - uses: actions/cache@v4 - with: - path: vendor - key: composer-${{ matrix.php-version }}-${{ hashFiles('composer.lock') }} - restore-keys: composer-${{ matrix.php-version }}- - - - name: Install dependencies - run: composer install --prefer-dist --no-interaction - - - name: Lint & unit tests - env: - STREAM_API_KEY: ${{ vars.STREAM_API_KEY }} - STREAM_API_SECRET: ${{ secrets.STREAM_API_SECRET }} - run: | - make lint - make test-unit - - - name: Integration tests - if: matrix.php-version == '8.1' - env: - STREAM_API_KEY: ${{ vars.STREAM_API_KEY }} - STREAM_API_SECRET: ${{ secrets.STREAM_API_SECRET }} - run: make test-integration + uses: ./.github/workflows/run_tests.yml + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9bb496..b019e32 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,199 +1,185 @@ name: Release on: + push: + branches: + - master + - '*.x' workflow_dispatch: inputs: - version_bump: - description: 'Version bump type for manual release' - required: true - default: 'patch' - type: choice - options: - - patch - - minor - - major - use_current_version: - description: 'Skip version bump and publish the version already set in composer.json' + resync_packagist: + description: 'Ask Packagist to recrawl the repository now, without running a release.' required: false default: false type: boolean - prerelease: - description: 'Mark GitHub release as prerelease' - required: false - default: false - type: boolean - pull_request: - types: [closed] - branches: - - main - - master + +permissions: + contents: read concurrency: - group: release-${{ github.event.pull_request.base.ref || github.ref_name }} + group: release-${{ github.ref_name }} cancel-in-progress: false -permissions: - contents: write - jobs: - release: - name: ๐Ÿš€ Release - environment: ci - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true + # Reversible half: keep the Release PR current. Stands down only while a release is + # already pending, because until that one is tagged there is no release commit to stop + # the walk at and it would propose the same commits again in a second Release PR. + release-pr: + name: Release PR + needs: detect + if: needs.detect.outputs.pending != 'true' runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + issues: write + pull-requests: write steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} - - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - uses: googleapis/release-please-action@v4 with: - php-version: '8.1' - tools: composer:v2 - - - name: Skip when PR is already released - id: already_released - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "value=false" >> "$GITHUB_OUTPUT" - else - if git log --oneline --grep="(pr #${{ github.event.pull_request.number }})" -n 1 | grep -q "chore(release):"; then - echo "value=true" >> "$GITHUB_OUTPUT" - else - echo "value=false" >> "$GITHUB_OUTPUT" - fi - fi - - - name: Determine and apply version bump (from PR metadata) - id: release_meta - if: github.event_name == 'pull_request' && steps.already_released.outputs.value != 'true' + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + target-branch: ${{ github.ref_name }} + skip-github-release: true + + # The tag and the GitHub Release are irreversible, so the suite has to run before them, + # which means knowing a release is pending before the suite starts. The tag lands on the + # merged Release PR's merge commit while the suite runs on this workflow's own commit, + # so `ready` also requires those to be the same commit. They are, on the path that + # matters: the push of that merge. + detect: + name: Detect pending release + if: >- + (github.event_name == 'push' || !inputs.resync_packagist) && + (github.ref_name == 'master' || endsWith(github.ref_name, '.x')) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + issues: read + outputs: + pending: ${{ steps.find.outputs.pending }} + ready: ${{ steps.find.outputs.ready }} + steps: + - name: Find a merged Release PR waiting to be tagged + id: find env: - PR_TITLE: ${{ github.event.pull_request.title }} - run: | - php scripts/release/bump_version.php \ - --title "$PR_TITLE" \ - --output "$GITHUB_OUTPUT" - - - name: Determine and apply version bump (manual) - id: release_meta_manual - if: github.event_name == 'workflow_dispatch' - run: | - php scripts/release/bump_version.php \ - --manual-bump "${{ github.event.inputs.version_bump }}" \ - --use-current-version "${{ github.event.inputs.use_current_version }}" \ - --output "$GITHUB_OUTPUT" - - - name: Consolidate release metadata - id: release_meta_final - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "should_release=${{ steps.release_meta_manual.outputs.should_release }}" >> "$GITHUB_OUTPUT" - echo "bump=${{ steps.release_meta_manual.outputs.bump }}" >> "$GITHUB_OUTPUT" - echo "previous_version=${{ steps.release_meta_manual.outputs.previous_version }}" >> "$GITHUB_OUTPUT" - echo "version=${{ steps.release_meta_manual.outputs.version }}" >> "$GITHUB_OUTPUT" - echo "tag=${{ steps.release_meta_manual.outputs.tag }}" >> "$GITHUB_OUTPUT" - else - echo "should_release=${{ steps.release_meta.outputs.should_release }}" >> "$GITHUB_OUTPUT" - echo "bump=${{ steps.release_meta.outputs.bump }}" >> "$GITHUB_OUTPUT" - echo "previous_version=${{ steps.release_meta.outputs.previous_version }}" >> "$GITHUB_OUTPUT" - echo "version=${{ steps.release_meta.outputs.version }}" >> "$GITHUB_OUTPUT" - echo "tag=${{ steps.release_meta.outputs.tag }}" >> "$GITHUB_OUTPUT" - fi - - - name: Stop when PR does not require release - if: steps.already_released.outputs.value == 'true' || steps.release_meta_final.outputs.should_release != 'true' + GH_TOKEN: ${{ github.token }} + BASE: ${{ github.ref_name }} + HEAD_SHA: ${{ github.sha }} run: | - if [ "${{ steps.already_released.outputs.value }}" = "true" ]; then - echo "PR #${{ github.event.pull_request.number }} is already released; skipping." - exit 0 - fi - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "Manual release was not requested; skipping." + pending=false + ready=false + + # Query the label directly. Listing closed PRs and filtering client-side loses + # a release that has slipped past the first page, which reads as "nothing to + # release" and passes. Filter before picking, too: a hotfix branch can hold + # its own pending release, and taking the newest label match would drop this + # branch's release on every run until the other one clears. + nums="$(gh api "repos/${GITHUB_REPOSITORY}/issues" \ + -X GET -f state=closed -f labels='autorelease: pending' -f per_page=20 \ + --jq '.[] | select(.pull_request != null) | .number')" + + num="" + sha="" + for n in $nums; do + sha="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${n}" \ + --jq 'select(.merged_at != null and .base.ref == env.BASE) | .merge_commit_sha // empty')" + if [ -n "$sha" ]; then + num="$n" + break + fi + done + + if [ -z "$sha" ]; then + echo "No pending release on ${BASE}." + elif [ "$sha" != "$HEAD_SHA" ]; then + # Reached when an earlier release run failed after the Release PR merged. + # Tagging $sha here would tag a tree this run never tested, and failing + # would redden every later push, so stand down and say why. + pending=true + echo "::warning::Release PR #${num} is still pending at ${sha}, which is not this run's commit ${HEAD_SHA}. Re-run the workflow run for ${sha} to finish that release." else - echo "No release type found in PR title/body; skipping." + pending=true + ready=true + echo "Pending release #${num} will be tagged at ${sha}." fi - exit 0 - - name: Commit version files - if: steps.already_released.outputs.value != 'true' && steps.release_meta_final.outputs.should_release == 'true' && github.event.inputs.use_current_version != 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add composer.json src/Constant.php - if git diff --cached --quiet; then - echo "No version changes to commit." - exit 0 - fi - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - git commit -m "chore(release): v${{ steps.release_meta_final.outputs.version }} (manual)" - git push origin "HEAD:${{ github.ref_name }}" - else - git commit -m "chore(release): v${{ steps.release_meta_final.outputs.version }} (pr #${{ github.event.pull_request.number }})" - git push origin "HEAD:${{ github.event.pull_request.base.ref }}" - fi + { + echo "pending=${pending}" + echo "ready=${ready}" + } >> "$GITHUB_OUTPUT" - - name: Create release tag - if: steps.already_released.outputs.value != 'true' && steps.release_meta_final.outputs.should_release == 'true' - run: | - if git rev-parse -q --verify "refs/tags/${{ steps.release_meta_final.outputs.tag }}" >/dev/null; then - echo "Tag ${{ steps.release_meta_final.outputs.tag }} already exists; skipping tag creation." - exit 0 - fi - git tag "${{ steps.release_meta_final.outputs.tag }}" - git push origin "${{ steps.release_meta_final.outputs.tag }}" + tests: + name: Tests + needs: detect + if: needs.detect.outputs.ready == 'true' + uses: ./.github/workflows/run_tests.yml + secrets: inherit - - name: Create release on GitHub - if: steps.already_released.outputs.value != 'true' && steps.release_meta_final.outputs.should_release == 'true' - uses: ncipollo/release-action@v1 + # Irreversible half. + release: + name: ๐Ÿš€ Tag and release + needs: [detect, tests] + if: needs.detect.outputs.ready == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + issues: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - uses: googleapis/release-please-action@v4 + id: release with: - tag: ${{ steps.release_meta_final.outputs.tag }} - skipIfReleaseExists: true - prerelease: ${{ github.event.inputs.prerelease == 'true' }} - token: ${{ secrets.GITHUB_TOKEN }} - body: | - Release v${{ steps.release_meta_final.outputs.version }} - - - Bump type: `${{ steps.release_meta_final.outputs.bump }}` - - Previous: `${{ steps.release_meta_final.outputs.previous_version }}` - - Next: `${{ steps.release_meta_final.outputs.version }}` - - Trigger: `${{ github.event_name }}` - - Install with: `composer require getstream/getstream-php:^${{ steps.release_meta_final.outputs.version }}` - + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + target-branch: ${{ github.ref_name }} + skip-github-pull-request: true + + # Packagist reads the tag itself; this only tells it to look now instead of on its own + # schedule. Idempotent, so the workflow_dispatch input re-announces an existing tag. + packagist: + name: ๐Ÿ“ฆ Announce to Packagist + needs: release + if: >- + !cancelled() && + (inputs.resync_packagist || needs.release.outputs.release_created == 'true') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: - name: Update Packagist - if: steps.already_released.outputs.value != 'true' && steps.release_meta_final.outputs.should_release == 'true' env: PACKAGIST_TOKEN: ${{ secrets.PACKAGIST_TOKEN }} PACKAGIST_USERNAME: ${{ vars.PACKAGIST_USERNAME }} run: | - if [ -z "$PACKAGIST_TOKEN" ]; then - echo "โš ๏ธ PACKAGIST_TOKEN secret is not set. Skipping Packagist update." - exit 0 - fi - - if [ -z "$PACKAGIST_USERNAME" ]; then - echo "โš ๏ธ PACKAGIST_USERNAME secret is not set. Skipping Packagist update." - echo "๐Ÿ’ก Add PACKAGIST_USERNAME secret with your Packagist account username." + if [ -z "$PACKAGIST_TOKEN" ] || [ -z "$PACKAGIST_USERNAME" ]; then + echo "::warning::PACKAGIST_TOKEN or PACKAGIST_USERNAME is unset, so Packagist was not told to recrawl. It will pick new tags up on its own schedule." exit 0 fi - - echo "๐Ÿ”„ Updating Packagist package..." + + # The API takes a repository, not a tag: it recrawls every tag it can see. + echo "Asking Packagist to recrawl the repository." response=$(curl -s -w "\n%{http_code}" -X POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $PACKAGIST_USERNAME:$PACKAGIST_TOKEN" \ -d '{"repository":{"url":"https://github.com/GetStream/getstream-php"}}' \ "https://packagist.org/api/update-package") - + http_code=$(echo "$response" | tail -n1) body=$(echo "$response" | sed '$d') - + if [ "$http_code" -eq 200 ] || [ "$http_code" -eq 202 ]; then - echo "โœ… Packagist update triggered successfully" + echo "Packagist update triggered." echo "$body" else - echo "โŒ Failed to update Packagist (HTTP $http_code)" + echo "::error::Packagist update failed (HTTP $http_code)" echo "$body" exit 1 fi diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml new file mode 100644 index 0000000..dd49348 --- /dev/null +++ b/.github/workflows/run_tests.yml @@ -0,0 +1,60 @@ +name: _run-tests + +on: + workflow_call: + secrets: + STREAM_API_SECRET: + required: true + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: ๐Ÿงช Test & lint + environment: ci + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + matrix: + php-version: ['8.1', '8.2', '8.3'] + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: curl, json + tools: composer:v2 + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: vendor + key: composer-${{ matrix.php-version }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php-version }}- + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction + + - name: Lint & unit tests + env: + STREAM_API_KEY: ${{ vars.STREAM_API_KEY }} + STREAM_API_SECRET: ${{ secrets.STREAM_API_SECRET }} + run: | + make lint + make test-unit + + - name: Integration tests + if: matrix.php-version == '8.1' + env: + STREAM_API_KEY: ${{ vars.STREAM_API_KEY }} + STREAM_API_SECRET: ${{ secrets.STREAM_API_SECRET }} + run: make test-integration diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..ca3a511 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "12.1.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c2458..9656b54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,40 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Entries from 12.1.1 on are generated by release-please from commit messages; earlier +ones were written by hand in the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +format. -## [Unreleased] +## [12.1.0] - 2026-09-10 + +### Changed + +- Regenerated the OpenAPI client (FEEDS-1763.1). + +## [12.0.0] - 2026-09-02 + +### Changed + +- **Breaking:** regenerated the OpenAPI client (FEEDS-1830). + +## [11.1.0] - 2026-08-25 + +### Changed + +- Regenerated from the latest OpenAPI (CHA-4947). + +## [11.0.0] - 2026-08-17 + +### Changed + +- **Breaking:** regenerated from chat v235.17.1. + +## [10.1.0] - 2026-08-12 + +### Fixed + +- Name the HTTP status when the error body is not JSON (#58). ## [10.0.0] - 2026-07-24 diff --git a/README.md b/README.md index b2b4a95..4441acb 100644 --- a/README.md +++ b/README.md @@ -226,23 +226,33 @@ This creates clean, typed models with automatic JSON handling - no boilerplate c ### Release Workflow -Releases are automated when a pull request is merged into `main` or `master`. - -- PR titles must follow Conventional Commit format (for example: `feat: ...`, `fix: ...`). -- Ticket prefix is required in the subject: `type: [FEEDS-1234] description`. -- Keep the commit type first so release automation can parse it. -- Version bump is derived from the PR title: - - `feat:` => minor - - `fix:` or `bug:` => patch - - `feat!:` / `fix!:` / `(scope)!:` (the `!` marker) => major -- Non-release types like `chore:`, `docs:`, `test:` do not create a release. -- The release workflow updates `composer.json` and `src/Constant.php`, pushes a tag, creates a GitHub release, and triggers Packagist. - -Examples: - -- `feat: [FEEDS-1350] add feed retention endpoint` -- `fix: [FEEDS-1402] handle missing reaction id` -- `feat!: [FEEDS-1410] remove deprecated follow API` +Releases are driven by [release-please](https://github.com/googleapis/release-please). + +- Merge PRs to `master` with conventional-commit titles, using **Squash and merge**. The + title becomes the commit subject and decides the next version: `feat:` is a minor, + `fix:` and `perf:` are a patch, `feat!:` or `(scope)!:` is a major. Other types + (`chore`, `ci`, `docs`, `test`, `refactor`) ship nothing. Keep the ticket prefix after + the type, as in `feat: [FEEDS-1350] add feed retention endpoint`. +- release-please keeps a Release PR open with the version bump in `composer.json`, + `src/Constant.php` and `CHANGELOG.md`. It is opened by `github-actions[bot]`, so + approve it and run its held checks like any other PR. Never edit those versions by + hand; the `// x-release-please-version` comment in `src/Constant.php` is what the + updater anchors on. +- Merging the Release PR runs lint, unit tests across PHP 8.1 to 8.3 and the integration + suite on that merge commit, which is the commit the tag will point at. Only if that is + green does the workflow create the tag and the GitHub Release and announce the tag to + Packagist. The order matters: a tag and a GitHub Release cannot be withdrawn. + +Packagist reads the tags itself, so the announce step only asks it to look now rather +than on its own schedule. If it fails or the credentials are unset, the release still +lands and Packagist catches up. To prompt it by hand, dispatch `Release` with +`resync_packagist` checked; the API takes a repository rather than a tag, so it recrawls +everything it can see. + +To force a specific version, type `Release-As: X.Y.Z` in the commit message box of the +squash dialog when merging a PR; the PR description is not copied there. To hotfix while +`master` carries unreleased work, branch `N.x` from the last tag, cherry-pick the fix, +and merge the Release PR that release-please opens against that branch. ### Linting and Code Quality diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..a96aa59 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "php", + "include-component-in-tag": false, + "extra-files": ["src/Constant.php"], + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true }, + { "type": "refactor", "section": "Code Refactoring", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true } + ] + } + } +} diff --git a/scripts/release/bump_version.php b/scripts/release/bump_version.php deleted file mode 100644 index e4e7351..0000000 --- a/scripts/release/bump_version.php +++ /dev/null @@ -1,246 +0,0 @@ - $value) { - if (str_starts_with($value, $prefix)) { - return (string) substr($value, strlen($prefix)); - } - - if ($value === '--' . $name && isset($argv[$index + 1])) { - return (string) $argv[$index + 1]; - } - } - - return $default; -} - -function runCommand(string $command): string -{ - $result = shell_exec($command); - return $result === null ? '' : trim($result); -} - -function findLatestSemverTag(): string -{ - $tagsRaw = runCommand('git tag --list'); - if ($tagsRaw === '') { - return '0.0.0'; - } - - $tags = preg_split('/\R/', $tagsRaw) ?: []; - $versions = []; - - foreach ($tags as $tag) { - $normalized = ltrim(trim($tag), 'v'); - if (preg_match('/^\d+\.\d+\.\d+$/', $normalized) === 1) { - $versions[] = $normalized; - } - } - - if ($versions === []) { - return '0.0.0'; - } - - usort($versions, 'version_compare'); - return end($versions) ?: '0.0.0'; -} - -function determineBumpType(string $title): string -{ - // Breaking changes are signalled only by the `!` marker in the title - // (e.g. `feat!:`). Free-text body/title prose is not trusted: a PR that - // merely mentions the major-bump phrase must not force a major bump. - $title = trim($title); - - if (preg_match('/^([a-z]+)(\([^)]+\))?(!)?:/i', $title, $matches) !== 1) { - return 'none'; - } - - $type = strtolower($matches[1]); - $isBreakingTitle = isset($matches[3]) && $matches[3] === '!'; - if ($isBreakingTitle) { - return 'major'; - } - - if ($type === 'feat') { - return 'minor'; - } - - if ($type === 'fix' || $type === 'bug') { - return 'patch'; - } - - return 'none'; -} - -function incrementVersion(string $version, string $bump): string -{ - $parts = array_map('intval', explode('.', $version)); - $major = $parts[0] ?? 0; - $minor = $parts[1] ?? 0; - $patch = $parts[2] ?? 0; - - if ($bump === 'major') { - $major++; - $minor = 0; - $patch = 0; - } elseif ($bump === 'minor') { - $minor++; - $patch = 0; - } elseif ($bump === 'patch') { - $patch++; - } - - return sprintf('%d.%d.%d', $major, $minor, $patch); -} - -function readComposerVersion(string $path): string -{ - $raw = file_get_contents($path); - if ($raw === false) { - throw new ReleaseScriptException('Could not read composer.json'); - } - - $decoded = json_decode($raw, true); - if (!is_array($decoded)) { - throw new ReleaseScriptException('Invalid composer.json'); - } - - $version = (string) ($decoded['version'] ?? ''); - $version = ltrim(trim($version), 'v'); - if (preg_match('/^\d+\.\d+\.\d+$/', $version) !== 1) { - throw new ReleaseScriptException('Could not parse semantic version from composer.json'); - } - - return $version; -} - -function updateComposerVersion(string $path, string $version): void -{ - $raw = file_get_contents($path); - if ($raw === false) { - throw new ReleaseScriptException('Could not read composer.json'); - } - - $count = 0; - $updated = preg_replace( - '/"version":\s*"[^"]*"/', - '"version": "v' . $version . '"', - $raw, - 1, - $count - ); - - if ($updated === null) { - throw new ReleaseScriptException('Regex failed while updating composer.json'); - } - - if ($count === 0) { - throw new ReleaseScriptException('Could not find version field in composer.json'); - } - - file_put_contents($path, $updated); -} - -function updateConstantVersion(string $path, string $version): void -{ - $raw = file_get_contents($path); - if ($raw === false) { - throw new ReleaseScriptException('Could not read Constant.php'); - } - - $updated = preg_replace( - "/public const VERSION = '[^']+';/", - "public const VERSION = '" . $version . "';", - $raw, - 1 - ); - - if ($updated === null) { - throw new ReleaseScriptException('Regex failed while updating Constant.php'); - } - - file_put_contents($path, $updated); -} - -function writeOutputs(string $outputPath, array $values): void -{ - if ($outputPath === '') { - foreach ($values as $key => $value) { - echo $key . '=' . $value . PHP_EOL; - } - return; - } - - $lines = []; - foreach ($values as $key => $value) { - $lines[] = $key . '=' . $value; - } - file_put_contents($outputPath, implode(PHP_EOL, $lines) . PHP_EOL, FILE_APPEND); -} - -$title = getArgValue($argv, 'title'); -$outputPath = getArgValue($argv, 'output'); -$manualBump = strtolower(trim(getArgValue($argv, 'manual-bump'))); -$useCurrentVersion = strtolower(trim(getArgValue($argv, 'use-current-version', 'false'))) === 'true'; - -if ($manualBump !== '') { - $allowed = ['major', 'minor', 'patch']; - if (!in_array($manualBump, $allowed, true)) { - throw new ReleaseScriptException('manual-bump must be one of: major, minor, patch'); - } - - $previousVersion = findLatestSemverTag(); - if ($useCurrentVersion) { - $nextVersion = readComposerVersion('composer.json'); - } else { - $nextVersion = incrementVersion($previousVersion, $manualBump); - updateComposerVersion('composer.json', $nextVersion); - updateConstantVersion('src/Constant.php', $nextVersion); - } - - writeOutputs($outputPath, [ - 'should_release' => 'true', - 'bump' => $manualBump, - 'previous_version' => $previousVersion, - 'version' => $nextVersion, - 'tag' => 'v' . $nextVersion, - ]); - exit(0); -} - -$bump = determineBumpType($title); -if ($bump === 'none') { - writeOutputs($outputPath, [ - 'should_release' => 'false', - 'bump' => 'none', - ]); - exit(0); -} - -$currentVersion = findLatestSemverTag(); -$nextVersion = incrementVersion($currentVersion, $bump); - -updateComposerVersion('composer.json', $nextVersion); -updateConstantVersion('src/Constant.php', $nextVersion); - -writeOutputs($outputPath, [ - 'should_release' => 'true', - 'bump' => $bump, - 'previous_version' => $currentVersion, - 'version' => $nextVersion, - 'tag' => 'v' . $nextVersion, -]); diff --git a/src/Constant.php b/src/Constant.php index d7dd407..25e6481 100644 --- a/src/Constant.php +++ b/src/Constant.php @@ -6,5 +6,5 @@ class Constant { - public const VERSION = '12.1.0'; + public const VERSION = '12.1.0'; // x-release-please-version }