diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b7e50d..6d6f3b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,8 @@ jobs: run: composer install --no-interaction --prefer-dist - name: Run quality and contract gates run: composer check + - name: Test release workflow detection + run: node --test tools/tests/release-detection.test.mjs compatibility: name: PHP ${{ matrix.php }} / ${{ matrix.dependencies }} @@ -69,8 +71,12 @@ jobs: uses: shivammathur/setup-php@db91e1a0e48e84637d325a7ee4d2677e146ceec4 # 2.36.0 with: php-version: "8.2" + # Keep branch-coverage semantics stable across runner image updates. + extensions: xdebug-3.5.3 coverage: xdebug tools: composer:v2 + - name: Verify pinned coverage driver + run: php -r 'if (phpversion("xdebug") !== "3.5.3") { fwrite(STDERR, "Expected Xdebug 3.5.3\n"); exit(1); }' - name: Install locked dependencies run: composer install --no-interaction --prefer-dist - name: Generate fresh line and branch coverage @@ -85,6 +91,7 @@ jobs: - name: Enforce complete line and branch coverage run: php tools/check-coverage.php build/coverage/summary.txt 100 - name: Upload fresh coverage evidence + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-evidence @@ -106,8 +113,11 @@ jobs: uses: shivammathur/setup-php@db91e1a0e48e84637d325a7ee4d2677e146ceec4 # 2.36.0 with: php-version: "8.3" + extensions: xdebug-3.5.3 coverage: xdebug tools: composer:v2 + - name: Verify pinned coverage driver + run: php -r 'if (phpversion("xdebug") !== "3.5.3") { fwrite(STDERR, "Expected Xdebug 3.5.3\n"); exit(1); }' - name: Install locked dependencies run: composer install --no-interaction --prefer-dist - name: Download and verify Infection 0.35.3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62512b0..f5def47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,37 +17,19 @@ jobs: prepare: name: Detect merged release branch runs-on: ubuntu-latest + timeout-minutes: 5 outputs: eligible: ${{ steps.release.outputs.eligible }} version: ${{ steps.release.outputs.version }} pull-request: ${{ steps.release.outputs.pull-request }} steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Resolve the merged release pull request id: release env: GH_TOKEN: ${{ github.token }} - run: | - pull_request="$(gh api \ - -H "Accept: application/vnd.github+json" \ - "/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \ - --jq '[.[] | select(.merged_at != null and .base.ref == "main" and (.head.ref | startswith("release/")))] | first')" - branch="$(printf '%s' "$pull_request" | jq -r '.head.ref // empty')" - number="$(printf '%s' "$pull_request" | jq -r '.number // empty')" - if [ -z "$branch" ]; then - echo "eligible=false" >> "$GITHUB_OUTPUT" - echo "No merged release/* pull request targets this main commit." - exit 0 - fi - version="${branch#release/}" - version="${version#v}" - if ! [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$ ]]; then - echo "Release branch '$branch' must end in a semantic version." >&2 - exit 1 - fi - echo "eligible=true" >> "$GITHUB_OUTPUT" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "pull-request=$number" >> "$GITHUB_OUTPUT" - echo "Release $version resolved from PR #$number ($branch)." + run: bash tools/resolve-release-pr.sh live-contract: name: Full live SDK contract @@ -75,8 +57,11 @@ jobs: uses: shivammathur/setup-php@db91e1a0e48e84637d325a7ee4d2677e146ceec4 # 2.36.0 with: php-version: "8.2" + extensions: xdebug-3.5.3 coverage: xdebug tools: composer:v2 + - name: Verify pinned coverage driver + run: php -r 'if (phpversion("xdebug") !== "3.5.3") { fwrite(STDERR, "Expected Xdebug 3.5.3\n"); exit(1); }' - name: Install locked dependencies run: composer install --no-interaction --prefer-dist - name: Audit locked dependencies @@ -177,6 +162,7 @@ jobs: mkdir -p build/packagist-consumer composer init --working-dir=build/packagist-consumer --name=fleetbase/release-verifier --no-interaction for attempt in $(seq 1 20); do + echo "Checking Packagist availability (attempt $attempt/20)." if composer show fleetbase/fleetbase-php "$RELEASE_VERSION" --all --no-interaction >/dev/null 2>&1; then composer require --working-dir=build/packagist-consumer "fleetbase/fleetbase-php:$RELEASE_VERSION" --no-interaction --prefer-dist composer install --working-dir=build/packagist-consumer --no-interaction --no-dev --optimize-autoloader diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 4108da1..269db82 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -34,3 +34,11 @@ composer install --no-dev --optimize-autoloader 6. Confirm the public API reference renders the checked-in 1.3.0 PHP examples. Never reuse, move, or rewrite a published tag. If validation fails after publication, publish a new patch release. + +## Release workflow troubleshooting + +- Coverage and mutation jobs, including release validation, pin Xdebug 3.5.3 and verify the loaded version. Update this pin deliberately in all three jobs with fresh coverage and mutation evidence; do not lower the 100% line/branch or 85% mutation gates to accommodate tooling drift. Coverage artifacts are uploaded even when the coverage gate fails. +- Release detection retries GitHub's commit-to-PR lookup six times, ten seconds apart, to allow merge metadata to become visible. Only a merged `release/` PR targeting `main` whose merge SHA exactly matches the triggering commit is eligible. API errors fail the job; exhausted successful lookups with no matching PR produce an explicit warning and skip publication. +- An ordinary fix PR does not trigger a release. After merging workflow repairs, use a fresh reviewed release PR for the still-unpublished version. Rerunning an older workflow run uses that run's original workflow and source, not workflow repairs merged afterward. No tag should be created manually to bypass validation or the protected release environment. + +Run the detector's offline regression suite with `node --test tools/tests/release-detection.test.mjs`. It uses simulated GitHub responses and does not create releases or require credentials. diff --git a/tools/resolve-release-pr.sh b/tools/resolve-release-pr.sh new file mode 100644 index 0000000..304a55b --- /dev/null +++ b/tools/resolve-release-pr.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# GitHub may not expose a merged PR immediately after its push event. +set -euo pipefail + +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +: "${GITHUB_SHA:?GITHUB_SHA is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +for attempt in 1 2 3 4 5 6; do + lookup_ok=false + if response=$(gh api --paginate --slurp \ + -H "Accept: application/vnd.github+json" \ + "/repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls"); then + if matches=$(printf '%s' "$response" | jq -ce --arg sha "$GITHUB_SHA" ' + [.[][] | select(.merged_at != null and .base.ref == "main" + and .merge_commit_sha == $sha and (.head.ref | startswith("release/")))] + '); then + lookup_ok=true + count=$(printf '%s' "$matches" | jq 'length') + if [ "$count" -gt 1 ]; then + echo "::error::Multiple release PRs match this merge commit; refusing ambiguous publication." >&2 + exit 1 + fi + if [ "$count" -eq 1 ]; then + branch=$(printf '%s' "$matches" | jq -r '.[0].head.ref') + number=$(printf '%s' "$matches" | jq -r '.[0].number') + version="${branch#release/}" + version="${version#v}" + if ! [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Release branch '$branch' must end in a semantic version." >&2 + exit 1 + fi + { + echo "eligible=true" + echo "version=$version" + echo "pull-request=$number" + } >> "$GITHUB_OUTPUT" + echo "Release $version resolved from PR #$number ($branch), attempt $attempt." + exit 0 + fi + fi + fi + if [ "$attempt" -lt 6 ]; then + echo "Release PR not yet resolved (attempt $attempt/6); retrying in 10 seconds." + sleep 10 + fi +done + +if [ "$lookup_ok" != true ]; then + echo "::error::GitHub release-PR lookup failed after 6 attempts; refusing to treat an API failure as an ineligible commit." >&2 + exit 1 +fi +echo "eligible=false" >> "$GITHUB_OUTPUT" +echo "::warning::No merged release/* PR matches this exact main commit after 6 attempts. If this was a release merge, rerun the workflow after GitHub finishes indexing it." diff --git a/tools/tests/release-detection.test.mjs b/tools/tests/release-detection.test.mjs new file mode 100644 index 0000000..35985ff --- /dev/null +++ b/tools/tests/release-detection.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; + +const sha = 'a'.repeat(40); +const release = { + number: 11, merged_at: '2026-09-10T03:05:16Z', merge_commit_sha: sha, + base: { ref: 'main' }, head: { ref: 'release/v1.3.0' }, +}; + +function detect(responses) { + const directory = mkdtempSync(join(tmpdir(), 'sdk-release-detection-')); + try { + writeFileSync(join(directory, 'responses.json'), JSON.stringify(responses)); + writeFileSync(join(directory, 'count'), '0'); + writeFileSync(join(directory, 'output'), ''); + writeFileSync(join(directory, 'gh'), `#!/usr/bin/env node +const fs = require('node:fs'); +const path = process.env.FIXTURE_PATH; +const count = Number(fs.readFileSync(path + '/count', 'utf8')); +fs.writeFileSync(path + '/count', String(count + 1)); +const args = process.argv.slice(2); +if (!args.includes('--paginate') || !args.includes('--slurp') || !args.includes('/repos/fleetbase/fleetbase-php/commits/' + process.env.GITHUB_SHA + '/pulls')) process.exit(98); +const responses = JSON.parse(fs.readFileSync(path + '/responses.json', 'utf8')); +const response = responses[Math.min(count, responses.length - 1)]; +if (response === 'api-error') process.exit(1); +process.stdout.write(response === 'malformed' ? '{' : JSON.stringify(response)); +`, { mode: 0o755 }); + writeFileSync(join(directory, 'sleep'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + const result = spawnSync('bash', [resolve('tools/resolve-release-pr.sh')], { + encoding: 'utf8', timeout: 15000, + env: { + ...process.env, PATH: `${directory}:${process.env.PATH}`, FIXTURE_PATH: directory, + GITHUB_REPOSITORY: 'fleetbase/fleetbase-php', GITHUB_SHA: sha, + GITHUB_OUTPUT: join(directory, 'output'), + }, + }); + assert.ifError(result.error); + return { ...result, output: readFileSync(join(directory, 'output'), 'utf8'), attempts: Number(readFileSync(join(directory, 'count'), 'utf8')) }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +test('resolves an immediately available release', () => { + const result = detect([[[release]]]); + assert.equal(result.status, 0); + assert.equal(result.attempts, 1); + assert.equal(result.output, 'eligible=true\nversion=1.3.0\npull-request=11\n'); +}); + +test('retries empty and not-yet-merged API results', () => { + const result = detect([[[]], [[{ ...release, merged_at: null }]], [[release]]]); + assert.equal(result.status, 0); + assert.equal(result.attempts, 3); + assert.match(result.output, /eligible=true/); +}); + +test('accepts a release on the final bounded attempt', () => { + const result = detect([[[]], [[]], [[]], [[]], [[]], [[release]]]); + assert.equal(result.status, 0); + assert.equal(result.attempts, 6); + assert.match(result.output, /eligible=true/); +}); + +test('handles pagination and versions without a v prefix', () => { + const result = detect([[[], [{ ...release, head: { ref: 'release/1.3.0-rc.1' } }]]]); + assert.equal(result.status, 0); + assert.match(result.output, /version=1.3.0-rc.1/); +}); + +test('does not publish ordinary, unmerged, wrong-base, or historical PRs', () => { + const result = detect([[ + [{ ...release, head: { ref: 'feature/fix' } }, { ...release, merged_at: null }], + [{ ...release, base: { ref: 'develop' } }, { ...release, merge_commit_sha: 'b'.repeat(40) }], + ]]); + assert.equal(result.status, 0); + assert.equal(result.attempts, 6); + assert.equal(result.output, 'eligible=false\n'); +}); + +test('warns when the association stays empty through exhaustion', () => { + const result = detect([[[]]]); + assert.equal(result.status, 0); + assert.equal(result.attempts, 6); + assert.equal(result.output, 'eligible=false\n'); + assert.match(result.stdout, /::warning::/); +}); + +test('recovers from API errors and malformed JSON', () => { + const result = detect(['api-error', 'malformed', [[release]]]); + assert.equal(result.status, 0); + assert.equal(result.attempts, 3); + assert.match(result.output, /eligible=true/); +}); + +for (const response of ['api-error', 'malformed']) { + test(`fails closed on exhausted ${response}`, () => { + const result = detect([response]); + assert.equal(result.status, 1); + assert.equal(result.attempts, 6); + assert.equal(result.output, ''); + assert.match(result.stderr, /::error::/); + }); +} + +test('rejects invalid release versions', () => { + const result = detect([[[{ ...release, head: { ref: 'release/not-a-version' } }]]]); + assert.equal(result.status, 1); + assert.equal(result.output, ''); +}); + +test('fails closed on ambiguous matching release PRs', () => { + const result = detect([[[release, { ...release, number: 12 }]]]); + assert.equal(result.status, 1); + assert.equal(result.output, ''); + assert.match(result.stderr, /ambiguous/); +});