From 835d49eb1f2f8b507ea36eae23e34515b1427b50 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 15 Sep 2026 13:03:46 +0200 Subject: [PATCH 1/2] ci: replace the PR-title bump script with release-please The bump script derived the version from the merged PR title and pushed the bump, the tag, the GitHub Release and the Packagist ping with no test gate in front of any of it. release-please keeps the bump in a reviewable Release PR instead, and the suite now runs before the tag. Unlike the other SDKs this repo needs no last-release-sha: its bump commit was pushed to master, so v12.1.0 is reachable and composer.json, src/Constant.php, the tag and Packagist all already agree on 12.1.0. src/Constant.php carries the version behind a // x-release-please-version comment, which is the only anchor the generic updater has. composer.json is handled by the php strategy, which writes a bare semver, so "v12.1.0" becomes "12.1.1" on the next release. Composer accepts both and Packagist derives versions from tags. changelog-sections overrides the php strategy's own default, which makes chore visible and would therefore release on a chore-only window. Every other SDK hides it, and one change landing as a release in php but not in go is the kind of difference nobody remembers. The test job moves into a reusable run_tests.yml that both ci.yml and the release gate call, and ci.yml drops its push trigger, which ran the suite twice per SHA and raced the integration leg against the Stream app several SDK repos share. --- .github/workflows/ci.yml | 62 ++----- .github/workflows/release.yml | 299 +++++++++++++++---------------- .github/workflows/run_tests.yml | 60 +++++++ .release-please-manifest.json | 3 + CHANGELOG.md | 36 +++- README.md | 43 +++-- release-please-config.json | 23 +++ scripts/release/bump_version.php | 246 ------------------------- src/Constant.php | 2 +- 9 files changed, 299 insertions(+), 475 deletions(-) create mode 100644 .github/workflows/run_tests.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json delete mode 100644 scripts/release/bump_version.php 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..cb19786 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,199 +1,184 @@ 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_tag: + description: 'Existing tag to re-announce to Packagist, e.g. v12.1.1. Leave empty for a normal release run.' 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 + default: '' + +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 + - uses: googleapis/release-please-action@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 - 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_tag == '') && + (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_tag != '' || 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 }} + TAG: ${{ inputs.resync_tag != '' && inputs.resync_tag || needs.release.outputs.tag_name }} 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 ${TAG} was not announced. Packagist will pick the tag up on its own schedule." exit 0 fi - - echo "๐Ÿ”„ Updating Packagist package..." + + echo "Announcing ${TAG} to Packagist." 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..930510f 100644 --- a/README.md +++ b/README.md @@ -226,23 +226,32 @@ 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 tag 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; dispatch `Release` with `resync_tag` set to the tag to +re-announce it. + +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 } From 5c973a9082f8b3a89ce7a3f3122dee2610ecfd2b Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 15 Sep 2026 15:10:16 +0200 Subject: [PATCH 2/2] docs: point RELEASE_SETUP at the new flow, and make the Packagist input honest RELEASE_SETUP.md described the bump script this PR deletes, as the live flow, and was the only surviving reference to that file. Its How It Works and Creating a Release sections now point at the README; the Packagist account, token and secrets setup stays, since none of that changed. resync_tag took a tag and never used it. The Packagist API posts a repository URL and recrawls every tag, so the input is now a boolean resync_packagist and the README says what it actually does. --- .github/RELEASE_SETUP.md | 30 +++++------------------------- .github/workflows/release.yml | 17 +++++++++-------- README.md | 7 ++++--- 3 files changed, 18 insertions(+), 36 deletions(-) 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/release.yml b/.github/workflows/release.yml index cb19786..b019e32 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,10 +7,11 @@ on: - '*.x' workflow_dispatch: inputs: - resync_tag: - description: 'Existing tag to re-announce to Packagist, e.g. v12.1.1. Leave empty for a normal release run.' + resync_packagist: + description: 'Ask Packagist to recrawl the repository now, without running a release.' required: false - default: '' + default: false + type: boolean permissions: contents: read @@ -49,7 +50,7 @@ jobs: detect: name: Detect pending release if: >- - (github.event_name == 'push' || inputs.resync_tag == '') && + (github.event_name == 'push' || !inputs.resync_packagist) && (github.ref_name == 'master' || endsWith(github.ref_name, '.x')) runs-on: ubuntu-latest timeout-minutes: 5 @@ -147,7 +148,7 @@ jobs: needs: release if: >- !cancelled() && - (inputs.resync_tag != '' || needs.release.outputs.release_created == 'true') + (inputs.resync_packagist || needs.release.outputs.release_created == 'true') runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -157,14 +158,14 @@ jobs: env: PACKAGIST_TOKEN: ${{ secrets.PACKAGIST_TOKEN }} PACKAGIST_USERNAME: ${{ vars.PACKAGIST_USERNAME }} - TAG: ${{ inputs.resync_tag != '' && inputs.resync_tag || needs.release.outputs.tag_name }} run: | if [ -z "$PACKAGIST_TOKEN" ] || [ -z "$PACKAGIST_USERNAME" ]; then - echo "::warning::PACKAGIST_TOKEN or PACKAGIST_USERNAME is unset, so ${TAG} was not announced. Packagist will pick the tag up on its own schedule." + 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 "Announcing ${TAG} to Packagist." + # 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" \ diff --git a/README.md b/README.md index 930510f..4441acb 100644 --- a/README.md +++ b/README.md @@ -243,10 +243,11 @@ Releases are driven by [release-please](https://github.com/googleapis/release-pl 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 tag itself, so the announce step only asks it to look now rather +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; dispatch `Release` with `resync_tag` set to the tag to -re-announce it. +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