Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
30 changes: 8 additions & 22 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/release-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
54 changes: 54 additions & 0 deletions tools/resolve-release-pr.sh
Original file line number Diff line number Diff line change
@@ -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."
121 changes: 121 additions & 0 deletions tools/tests/release-detection.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading