diff --git a/.github/RELEASE_SETUP.md b/.github/RELEASE_SETUP.md new file mode 100644 index 00000000..d5112ae3 --- /dev/null +++ b/.github/RELEASE_SETUP.md @@ -0,0 +1,111 @@ +# Release Setup Guide + +This guide explains how to set up automatic publishing to Packagist using GitHub Actions. + +## Prerequisites + +1. **Packagist Account**: Create an account at [packagist.org](https://packagist.org) +2. **GitHub Repository**: Ensure this repository is public on GitHub +3. **Packagist API Token**: Generate a token for automatic updates + +## Setup Steps + +### 1. Create Packagist Account and Submit Package + +1. Go to [packagist.org](https://packagist.org) and create an account +2. Click "Submit" and enter your repository URL: `https://github.com/GetStream/getstream-php` +3. Packagist will automatically detect the package name from `composer.json` + +### 2. Generate Packagist API Token + +1. Go to your Packagist profile: https://packagist.org/profile/ +2. Click "Show API Token" in the "API Token" section +3. Copy the token (it looks like: `pkg_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`) + +### 3. Add GitHub Secrets + +1. Go to your GitHub repository: https://github.com/GetStream/getstream-php +2. Click "Settings" โ†’ "Secrets and variables" โ†’ "Actions" +3. Click "New repository secret" +4. Add these secrets: + + - **Name**: `PACKAGIST_TOKEN` + **Value**: Your Packagist API token from step 2 + + - **Name**: `STREAM_API_KEY` + **Value**: Your Stream API key (for running tests) + + - **Name**: `STREAM_API_SECRET` + **Value**: Your Stream API secret (for running tests) + +### 4. Enable Packagist Auto-Update + +1. In your Packagist package page, go to "Settings" +2. Enable "Update by GitHub Hook" +3. Add the GitHub webhook URL: `https://packagist.org/api/github?username=YOUR_USERNAME&packageName=getstream/getstream-php` + +## 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: + - `feat:` => minor + - `fix:` or `bug:` => patch + - `feat!:` / `fix!:` / `BREAKING CHANGE` => 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. + +### Manual Publishing (if needed) + +If automatic publishing fails, you can manually trigger Packagist updates: + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_PACKAGIST_TOKEN" \ + -d '{"repository":{"url":"https://github.com/GetStream/getstream-php"}}' \ + https://packagist.org/api/update-package?username=YOUR_USERNAME +``` + +## Troubleshooting + +### Common Issues + +1. **"Package not found"**: Ensure the package is submitted to Packagist first +2. **"Invalid token"**: Verify the Packagist token is correct +3. **"Tests failing"**: Check that all tests pass before creating a release +4. **"Version already exists"**: Use a new version number + +### Checking Status + +- **GitHub Actions**: https://github.com/GetStream/getstream-php/actions +- **Packagist Package**: https://packagist.org/packages/getstream/getstream-php +- **Test Installation**: `composer require getstream/getstream-php` + +## Security Notes + +- Never commit API tokens to the repository +- Use GitHub Secrets for all sensitive data +- Regularly rotate your Packagist token +- Monitor the Actions logs for any security issues + + + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c36c61..6d2b2aae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,23 @@ on: branches: [master, main] concurrency: - group: ${{ github.workflow }}-${{ github.head_ref }} + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: + check-pr-title: + name: Validate PR title + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: aslafy-z/conventional-pr-title-action@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + test: name: ๐Ÿงช Test & lint + needs: check-pr-title + if: always() && (github.event_name != 'pull_request' || needs.check-pr-title.result == 'success') environment: ci runs-on: ubuntu-latest diff --git a/.github/workflows/initiate_release.yml b/.github/workflows/initiate_release.yml deleted file mode 100644 index 3e380758..00000000 --- a/.github/workflows/initiate_release.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Create release PR - -on: - workflow_dispatch: - inputs: - version: - description: "The new version number. Example: 1.0.1" - required: true - type: string - -jobs: - init_release: - name: ๐Ÿš€ Create release PR - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Update version in composer.json and Constant.php - env: - VERSION: ${{ github.event.inputs.version }} - run: | - # Update composer.json - sed -i 's/"version": ".*"/"version": "v'$VERSION'"/' composer.json - # Update Constant.php (remove 'v' prefix for constant) - sed -i "s/const VERSION = '.*'/const VERSION = '$VERSION'/" src/Constant.php - git config --global user.name 'github-actions' - git config --global user.email 'release@getstream.io' - git checkout -q -b "release-$VERSION" - git add composer.json src/Constant.php - git commit -am "chore(release): $VERSION" - git push -q -u origin "release-$VERSION" - - - name: Open pull request - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh pr create \ - -t "Release ${{ github.event.inputs.version }}" \ - -b "# :rocket: ${{ github.event.inputs.version }} - Make sure to use squash & merge when merging! - Once this is merged, another job will kick off automatically and publish the package. - - ## Changes - - Updated version in composer.json to v${{ github.event.inputs.version }} - - Updated version in Constant.php to ${{ github.event.inputs.version }}" diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml deleted file mode 100644 index 75a50e5d..00000000 --- a/.github/workflows/prerelease.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Pre-release - -on: - release: - types: [prereleased] - -jobs: - prerelease: - name: ๐Ÿš€ Pre-release - environment: ci - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Update Packagist - 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 var is not set. Skipping Packagist update." - exit 0 - fi - - echo "๐Ÿ”„ Updating Packagist package..." - 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 "$body" - else - echo "โŒ Failed to update Packagist (HTTP $http_code)" - echo "$body" - exit 1 - fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08207f8f..4f34f6f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,38 +4,103 @@ on: pull_request: types: [closed] branches: + - main - master +concurrency: + group: release-${{ github.event.pull_request.base.ref }} + cancel-in-progress: true + +permissions: + contents: write + jobs: release: name: ๐Ÿš€ Release environment: ci - if: github.event.pull_request.merged && startsWith(github.head_ref, 'release-') + if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - - uses: actions/github-script@v7 + ref: ${{ github.event.pull_request.base.ref }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 with: - script: | - // Getting the release version from the PR source branch - // Source branch looks like this: release-1.0.0 - const version = context.payload.pull_request.head.ref.split('-')[1] - core.exportVariable('VERSION', version) + php-version: '8.1' + tools: composer:v2 + + - name: Skip when PR is already released + id: already_released + run: | + 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 + + - name: Determine and apply version bump + id: release_meta + if: steps.already_released.outputs.value != 'true' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + PR_BODY_FILE=$(mktemp) + printf '%s' "$PR_BODY" > "$PR_BODY_FILE" + php scripts/release/bump_version.php \ + --title "$PR_TITLE" \ + --body-file "$PR_BODY_FILE" \ + --output "$GITHUB_OUTPUT" + + - name: Stop when PR does not require release + if: steps.already_released.outputs.value == 'true' || steps.release_meta.outputs.should_release != 'true' + run: | + if [ "${{ steps.already_released.outputs.value }}" = "true" ]; then + echo "PR #${{ github.event.pull_request.number }} is already released; skipping." + exit 0 + fi + echo "No release type found in PR title; skipping." + exit 0 + + - name: Commit version files + if: steps.already_released.outputs.value != 'true' && steps.release_meta.outputs.should_release == '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 + git commit -m "chore(release): v${{ steps.release_meta.outputs.version }} (pr #${{ github.event.pull_request.number }})" + git push origin "HEAD:${{ github.event.pull_request.base.ref }}" + + - name: Create release tag + if: steps.already_released.outputs.value != 'true' && steps.release_meta.outputs.should_release == 'true' + run: | + git tag "${{ steps.release_meta.outputs.tag }}" + git push origin "${{ steps.release_meta.outputs.tag }}" - name: Create release on GitHub + if: steps.already_released.outputs.value != 'true' && steps.release_meta.outputs.should_release == 'true' uses: ncipollo/release-action@v1 with: - tag: ${{ env.VERSION }} + tag: ${{ steps.release_meta.outputs.tag }} token: ${{ secrets.GITHUB_TOKEN }} body: | - Release ${{ env.VERSION }} - - Install with: `composer require getstream/getstream-php:^${{ env.VERSION }}` + Release v${{ steps.release_meta.outputs.version }} + + - Bump type: `${{ steps.release_meta.outputs.bump }}` + - Previous: `${{ steps.release_meta.outputs.previous_version }}` + - Next: `${{ steps.release_meta.outputs.version }}` + + Install with: `composer require getstream/getstream-php:^${{ steps.release_meta.outputs.version }}` - name: Update Packagist + if: steps.already_released.outputs.value != 'true' && steps.release_meta.outputs.should_release == 'true' env: PACKAGIST_TOKEN: ${{ secrets.PACKAGIST_TOKEN }} PACKAGIST_USERNAME: ${{ vars.PACKAGIST_USERNAME }} diff --git a/README.md b/README.md index 75522b27..430dea72 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,26 @@ This creates clean, typed models with automatic JSON handling - no boilerplate c ## Development +### 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 PR title/body: + - `feat:` => minor + - `fix:` or `bug:` => patch + - `feat!:` / `fix!:` / `BREAKING CHANGE` => 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` + ### Linting and Code Quality ```bash diff --git a/scripts/release/bump_version.php b/scripts/release/bump_version.php new file mode 100644 index 00000000..09a21db1 --- /dev/null +++ b/scripts/release/bump_version.php @@ -0,0 +1,210 @@ + $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 $body): string +{ + $title = trim($title); + $body = trim($body); + + if (preg_match('/BREAKING[ -]CHANGE/i', $body) === 1) { + return 'major'; + } + + 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 updateComposerVersion(string $path, string $version): void +{ + $raw = file_get_contents($path); + if ($raw === false) { + throw new ReleaseScriptException('Could not read composer.json'); + } + + $updated = preg_replace( + '/"version":\s*"[^"]*"/', + '"version": "v' . $version . '"', + $raw, + 1 + ); + + if ($updated === null || $updated === $raw) { + throw new ReleaseScriptException('Could not update version 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); +} + +function resolveBody(array $argv): string +{ + $bodyFile = getArgValue($argv, 'body-file'); + if ($bodyFile !== '') { + $raw = file_get_contents($bodyFile); + if ($raw === false) { + throw new ReleaseScriptException('Could not read body-file'); + } + return $raw; + } + + return getArgValue($argv, 'body'); +} + +$title = getArgValue($argv, 'title'); +$body = resolveBody($argv); +$outputPath = getArgValue($argv, 'output'); + +$bump = determineBumpType($title, $body); +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, +]);