From 35b6b9cf0648fb7013add7b8a4533602a543284b Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 9 Sep 2026 11:22:08 +0100 Subject: [PATCH 1/5] Add the release pre-flight script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One dependency-free PHP script, shared by release.yml and the check.yml dry-run job, so the release gate and the continuous gate cannot drift. It reports every failure rather than the first, and pushes nothing: the release workflow only starts publishing once this exits 0. Checks: the version input's shape; Defaults::LIB_VERSION and the top CHANGELOG.md heading agree with it (with --dry-run they only have to agree with each other, since there is no authoritative version on a PR); composer.json names ably/pubsub-server, which is what makes the copy of release.yml on main inert against the legacy layout; composer validate --strict; that the release's own tags do not already exist somewhere other than the dispatched commit; and the invariant the distribution mirror exists to protect — that no Composer-valid tag at or above 2.0.0 exists in this repository, because the legacy ably/ably-php Packagist package indexes every Composer-valid tag here regardless of its composer.json name. An existing tag that already points at HEAD is reported as a re-run rather than a failure, so a partial release can be completed by dispatching the same version again. Co-Authored-By: Claude Opus 5 (1M context) --- .gitattributes | 1 + scripts/release-preflight.php | 417 ++++++++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 scripts/release-preflight.php diff --git a/.gitattributes b/.gitattributes index be2d039..6309062 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,7 @@ /.github export-ignore /phpunit.xml export-ignore /plan.md export-ignore +/scripts export-ignore /.ably export-ignore /.gitmodules export-ignore /.gitattributes export-ignore diff --git a/scripts/release-preflight.php b/scripts/release-preflight.php new file mode 100644 index 0000000..faf91ee --- /dev/null +++ b/scripts/release-preflight.php @@ -0,0 +1,417 @@ + distribution mirror to check for an existing tag. + * Default: ably/ably-pubsub-php-dist. + * + * Exit codes: 0 all checks passed; 1 one or more checks failed; 2 bad invocation. + */ + +const EXPECTED_PACKAGE_NAME = 'ably/pubsub-server'; +const DEFAULT_MIRROR = 'ably/ably-pubsub-php-dist'; + +// The version at and above which a Composer-valid tag in *this* repository is a +// release-blocking emergency: the legacy `ably/ably-php` Packagist package indexes +// every Composer-valid tag here, so a plain `2.0.0` tag would make it serve the new +// package's code as its own latest version. See plan.md step 1 ("Why a mirror"). +const FIRST_NEW_MAJOR = 2; + +const VERSION_PATTERN = '/^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/'; +const COMPOSER_VALID_TAG_PATTERN = '/^v?(\d+)\.(\d+)\.(\d+)/'; + +$root = dirname(__DIR__); + +$options = parseArgv($argv); +$version = $options['version']; +$dryRun = $options['dry-run']; +$skipRemote = $options['skip-remote'] || $dryRun; +$mirror = $options['mirror'] ?? DEFAULT_MIRROR; + +if ($dryRun && $version !== null) { + fail_invocation('--dry-run and --version are mutually exclusive: a dry run has no authoritative version.'); +} +if (!$dryRun && $version === null) { + fail_invocation('--version is required unless --dry-run is given.'); +} + +$errors = []; +$notices = []; + +echo $dryRun + ? "Release pre-flight (dry run: version sites must agree with each other)\n\n" + : "Release pre-flight for {$version}\n\n"; + +// --------------------------------------------------------------------------- +// 1. The version input is a shape Composer will accept as a version. +// --------------------------------------------------------------------------- +if ($version !== null && !preg_match(VERSION_PATTERN, $version)) { + $errors[] = "version input '{$version}' is not X.Y.Z or X.Y.Z-suffix"; +} + +// --------------------------------------------------------------------------- +// 2. Defaults::LIB_VERSION — the only version site in the source tree. +// --------------------------------------------------------------------------- +$libVersion = readLibVersion($root . '/src/Defaults.php', $errors); + +// --------------------------------------------------------------------------- +// 3. The top `## [x.y.z]` heading in CHANGELOG.md. +// --------------------------------------------------------------------------- +$changelogVersion = readChangelogVersion($root . '/CHANGELOG.md', $errors); + +// --------------------------------------------------------------------------- +// 4. Every version site agrees. With --version that is the authority; in a dry run +// there is none, so the sites only have to agree with each other. +// --------------------------------------------------------------------------- +if ($version !== null) { + if ($libVersion !== null && $libVersion !== $version) { + $errors[] = "Defaults::LIB_VERSION is '{$libVersion}', expected '{$version}'"; + } + if ($changelogVersion !== null && $changelogVersion !== $version) { + $errors[] = "the top CHANGELOG.md heading is '{$changelogVersion}', expected '{$version}'"; + } +} elseif ($libVersion !== null && $changelogVersion !== null && $libVersion !== $changelogVersion) { + $errors[] = "Defaults::LIB_VERSION is '{$libVersion}' but the top CHANGELOG.md heading is " + . "'{$changelogVersion}' — bump both in the same PR"; +} elseif ($libVersion !== null) { + $notices[] = "version sites agree at {$libVersion}"; +} + +// --------------------------------------------------------------------------- +// 5. composer.json declares the new package. This is the check that makes the copy +// of release.yml on `main` inert while the split still lives on integration/v2: +// dispatched against the legacy layout it refuses before anything is pushed. +// --------------------------------------------------------------------------- +$composerName = readComposerName($root . '/composer.json', $errors); +if ($composerName !== null && $composerName !== EXPECTED_PACKAGE_NAME) { + $errors[] = "composer.json name is '{$composerName}', expected '" . EXPECTED_PACKAGE_NAME . "' — " + . 'this ref does not carry the ably/pubsub-server package, so there is nothing to release from it'; +} + +// --------------------------------------------------------------------------- +// 6. composer validate --strict. Packagist rejects nothing, so an invalid manifest +// ships silently; --strict is also what CI runs on every PR. +// --------------------------------------------------------------------------- +validateComposerManifest($root, $errors, $notices); + +// --------------------------------------------------------------------------- +// 7. No Composer-valid 2.x-or-later tag exists in this repository. +// This is the invariant the whole mirror arrangement exists to protect. +// --------------------------------------------------------------------------- +$localTags = gitTags($root, $errors); +$remoteTags = $skipRemote ? [] : (gitLsRemoteTags($root, 'origin', $errors) ?? []); +$repoTags = array_values(array_unique(array_merge($localTags, array_keys($remoteTags)))); + +$offendingTags = array_values(array_filter($repoTags, static function (string $tag): bool { + if (!preg_match(COMPOSER_VALID_TAG_PATTERN, $tag, $m)) { + return false; + } + return (int) $m[1] >= FIRST_NEW_MAJOR; +})); +sort($offendingTags); + +if ($offendingTags !== []) { + $errors[] = sprintf( + "this repository carries Composer-valid tag(s) at or above %d.0.0: %s\n" + . " These are indexed by the legacy ably/ably-php Packagist package, which is bound to\n" + . " this repository and ignores every tag's composer.json name. Any such tag makes\n" + . " `composer require ably/ably-php` resolve to ably/pubsub-server code. Delete the tag\n" + . " locally and on origin, then delete the version on Packagist. Releases here are\n" + . " tagged pubsub-server/; the plain tag exists only on the mirror.", + FIRST_NEW_MAJOR, + implode(', ', $offendingTags) + ); +} else { + $notices[] = sprintf( + 'no Composer-valid tag at or above %d.0.0 in this repository (%d tag(s) inspected%s)', + FIRST_NEW_MAJOR, + count($repoTags), + $skipRemote ? ', local only' : '' + ); +} + +// --------------------------------------------------------------------------- +// 8. The release's own tags do not already exist — unless they point at HEAD, which +// is a re-run of the same commit and is expected to complete a partial release. +// --------------------------------------------------------------------------- +if ($version !== null) { + $head = gitHead($root, $errors); + $namespacedTag = 'pubsub-server/' . $version; + + $localTarget = in_array($namespacedTag, $localTags, true) + ? gitRevParse($root, $namespacedTag . '^{commit}', $errors) + : null; + $remoteTarget = $remoteTags[$namespacedTag] ?? null; + + checkExistingTag( + "tag {$namespacedTag} in this repository", + $localTarget ?? $remoteTarget, + $head, + $errors, + $notices + ); + + if (!$skipRemote) { + $mirrorTags = gitLsRemoteTags($root, 'https://github.com/' . $mirror . '.git', $errors, $mirror); + if ($mirrorTags !== null) { + checkExistingTag( + "tag {$version} on the mirror {$mirror}", + $mirrorTags[$version] ?? null, + $head, + $errors, + $notices + ); + } + } else { + $notices[] = "skipped the mirror tag check for {$mirror} (--skip-remote)"; + } +} + +// --------------------------------------------------------------------------- +// Report. +// --------------------------------------------------------------------------- +foreach ($notices as $notice) { + echo " ok {$notice}\n"; +} +if ($errors === []) { + echo "\nPre-flight OK.\n"; + exit(0); +} + +echo "\n" . count($errors) . " pre-flight failure(s):\n"; +foreach ($errors as $error) { + echo " FAIL {$error}\n"; +} +echo "\nNothing has been pushed. Fix all of the above and dispatch again.\n"; +exit(1); + +// --------------------------------------------------------------------------- +// Helpers. +// --------------------------------------------------------------------------- + +/** + * @param list $argv + * @return array{version: ?string, dry-run: bool, skip-remote: bool, mirror: ?string} + */ +function parseArgv(array $argv): array +{ + $parsed = ['version' => null, 'dry-run' => false, 'skip-remote' => false, 'mirror' => null]; + + for ($i = 1; $i < count($argv); $i++) { + $arg = $argv[$i]; + if ($arg === '--dry-run' || $arg === '--skip-remote') { + $parsed[substr($arg, 2)] = true; + continue; + } + if ($arg === '--version' || $arg === '--mirror') { + $key = substr($arg, 2); + if (!isset($argv[$i + 1])) { + fail_invocation("{$arg} needs a value"); + } + $parsed[$key] = $argv[++$i]; + continue; + } + if (preg_match('/^--(version|mirror)=(.*)$/', $arg, $m)) { + $parsed[$m[1]] = $m[2]; + continue; + } + fail_invocation("unknown argument '{$arg}'"); + } + + return $parsed; +} + +function fail_invocation(string $message): void +{ + fwrite(STDERR, "release-preflight: {$message}\n"); + fwrite(STDERR, "Usage: php scripts/release-preflight.php (--version X.Y.Z | --dry-run) [--skip-remote] [--mirror owner/repo]\n"); + exit(2); +} + +/** @param list $errors */ +function readLibVersion(string $path, array &$errors): ?string +{ + $source = @file_get_contents($path); + if ($source === false) { + $errors[] = "cannot read {$path}"; + return null; + } + if (!preg_match("/const\s+LIB_VERSION\s*=\s*'([^']+)'/", $source, $m)) { + $errors[] = "cannot find Defaults::LIB_VERSION in {$path}"; + return null; + } + return $m[1]; +} + +/** @param list $errors */ +function readChangelogVersion(string $path, array &$errors): ?string +{ + $source = @file_get_contents($path); + if ($source === false) { + $errors[] = "cannot read {$path}"; + return null; + } + // The first `## [x.y.z]` heading in the file is the version being released. + if (!preg_match('/^##\s*\[([^\]]+)\]/m', $source, $m)) { + $errors[] = "cannot find a '## [x.y.z]' heading in {$path}"; + return null; + } + return $m[1]; +} + +/** @param list $errors */ +function readComposerName(string $path, array &$errors): ?string +{ + $source = @file_get_contents($path); + if ($source === false) { + $errors[] = "cannot read {$path}"; + return null; + } + $manifest = json_decode($source, true); + if (!is_array($manifest)) { + $errors[] = "{$path} is not valid JSON: " . json_last_error_msg(); + return null; + } + if (!isset($manifest['name']) || !is_string($manifest['name'])) { + $errors[] = "{$path} has no 'name'"; + return null; + } + return $manifest['name']; +} + +/** + * @param list $errors + * @param list $notices + */ +function validateComposerManifest(string $root, array &$errors, array &$notices): void +{ + if (run('command -v composer', $root, $output) !== 0) { + $notices[] = 'skipped composer validate --strict (composer is not on PATH)'; + return; + } + if (run('composer validate --strict --no-interaction 2>&1', $root, $output) !== 0) { + $errors[] = "composer validate --strict failed:\n " . str_replace("\n", "\n ", trim($output)); + return; + } + $notices[] = 'composer validate --strict passed'; +} + +/** + * @param list $errors + * @return list + */ +function gitTags(string $root, array &$errors): array +{ + if (run('git tag --list 2>&1', $root, $output) !== 0) { + $errors[] = "cannot list local git tags: " . trim($output); + return []; + } + return array_values(array_filter(array_map('trim', explode("\n", $output)), static fn ($t) => $t !== '')); +} + +/** + * @param list $errors + * @return ?array tag name => commit SHA, or null if the remote is unreachable + */ +function gitLsRemoteTags(string $root, string $remote, array &$errors, ?string $label = null): ?array +{ + $label ??= $remote; + if (run('git ls-remote --tags ' . escapeshellarg($remote) . ' 2>&1', $root, $output) !== 0) { + $errors[] = "cannot list tags on {$label}:\n " + . str_replace("\n", "\n ", trim($output)) + . "\n (pass --skip-remote to run the local checks only; the mirror repository must exist" + . "\n before the first release — see plan.md steps 9 and 15c)"; + return null; + } + + $tags = []; + foreach (explode("\n", $output) as $line) { + if (!preg_match('#^([0-9a-f]{40})\s+refs/tags/(.+?)(\^\{\})?$#', trim($line), $m)) { + continue; + } + // A peeled `^{}` line carries the commit an annotated tag points at; prefer it. + if (isset($m[3]) && $m[3] !== '') { + $tags[$m[2]] = $m[1]; + } elseif (!isset($tags[$m[2]])) { + $tags[$m[2]] = $m[1]; + } + } + return $tags; +} + +/** @param list $errors */ +function gitHead(string $root, array &$errors): ?string +{ + return gitRevParse($root, 'HEAD', $errors); +} + +/** @param list $errors */ +function gitRevParse(string $root, string $rev, array &$errors): ?string +{ + if (run('git rev-parse ' . escapeshellarg($rev) . ' 2>&1', $root, $output) !== 0) { + $errors[] = "cannot resolve {$rev}: " . trim($output); + return null; + } + return trim($output); +} + +/** + * An existing tag is fatal unless it already points at the commit being released, in + * which case this is a re-run and the workflow's steps will skip their own artifacts. + * + * @param list $errors + * @param list $notices + */ +function checkExistingTag( + string $what, + ?string $existingTarget, + ?string $head, + array &$errors, + array &$notices +): void { + if ($existingTarget === null) { + $notices[] = "{$what} does not exist yet"; + return; + } + if ($head !== null && $existingTarget === $head) { + $notices[] = "{$what} already exists at HEAD — treating this as a re-run of a partial release"; + return; + } + $errors[] = "{$what} already exists and points at {$existingTarget}, not the dispatched commit " + . ($head ?? 'HEAD') . ' — releasing a version twice from two different commits is never right'; +} + +/** Runs $command in $root, capturing combined output into $output. */ +function run(string $command, string $root, ?string &$output): int +{ + $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $process = proc_open(['/bin/sh', '-c', $command], $descriptors, $pipes, $root); + if (!is_resource($process)) { + $output = 'failed to start /bin/sh'; + return 127; + } + $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return proc_close($process); +} From a766778f640d993042550883da0add873b118f37 Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 9 Sep 2026 11:24:23 +0100 Subject: [PATCH 2/5] Add release.yml: publish through the distribution mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no registry upload step in PHP — Packagist serves whatever git tags a repository carries — so "publishing" here means pushing the release commit and the plain version tag to a mirror repository, tagging and releasing here, and proving Packagist picked it up. The mirror exists because a Packagist package indexes every Composer-valid tag of the repository it is bound to, whatever that tag's composer.json says (composer's VcsRepository::preProcess overwrites each version's name with the default branch's, and packagist's Updater then stamps the package's own name over it). The legacy ably/ably-php package stays bound to this repository, so a plain 2.0.0 tag here would be served as its latest version to every `ably/ably-php: *` consumer. Hence: plain tags only on ably/ably-pubsub-php-dist, namespaced pubsub-server/ tags only here. workflow_dispatch only, permissions {} at the top with contents: write on the job alone. The pre-flight runs before anything is pushed and the unit-level tests run with it. Every publishing step checks for its own artifact and skips it, so a partial run is completed by dispatching the same version again. The mirror push needs a MIRROR_PUSH_TOKEN secret: GITHUB_TOKEN cannot reach another repository. Neither the mirror repo nor the secret exists yet; both are admin prerequisites, and until the mirror exists the pre-flight fails on its tag check and nothing is pushed anywhere. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 239 ++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5ae447e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,239 @@ +name: Release + +# Releases ably/pubsub-server. +# +# WHY THIS WORKFLOW LOOKS NOTHING LIKE THE OTHER SDKS' RELEASE WORKFLOWS +# +# There is no registry upload step in PHP: Packagist serves whatever git tags a +# repository carries. Which turns out to be the whole problem. +# +# Verified against the composer/packagist and composer/composer sources: a Packagist +# package indexes *every* Composer-valid tag of the repository it is bound to, +# whatever that tag's composer.json says. Composer's `VcsRepository::preProcess` +# deliberately overwrites every tag's `name` with the default branch's name ("this +# ensures that a package can be renamed in one place and that all old tags will still +# be installable using that new name"), and Packagist's `Updater` then stamps its own +# package name on every version. There is no name-based filtering of versions +# anywhere in that path. +# +# So the legacy `ably/ably-php` package — which stays bound to this repository, because +# its ~8.7M downloads put it behind Packagist's `PopularPackageSafetyValidator` and its +# URL therefore cannot be moved — would serve any plain `2.0.0` tag pushed here as its +# own latest version. Every `ably/ably-php: *` or `>=1.1` consumer would upgrade into +# a package with a different name and a different namespace. Nothing in Packagist +# prevents that; only not doing it does. +# +# Hence the two rules this workflow implements: +# +# 1. `ably/pubsub-server` is published from a read-only distribution mirror, +# `ably/ably-pubsub-php-dist`. That is where the plain `` tag lives. +# NOTE: the mirror repository does not exist yet — it is an admin prerequisite +# (plan.md steps 9 and 15c), along with registering the package on Packagist and +# adding the same Packagist webhook to it. Until it exists, the pre-flight fails +# on the mirror tag check and nothing is pushed anywhere. +# 2. This repository only ever carries namespaced `pubsub-server/` tags, +# which Composer skips as invalid version names (`VcsRepository::validateTag`), so +# the legacy package never sees them. It keeps indexing plain `1.x` tags from +# `maintenance/1.x`, which is exactly what it should do. +# +# The pre-flight enforces rule 2 on every dispatch, and `check.yml` runs the same +# script on every pull request. +# +# CREDENTIAL: pushing to another repository needs more than the job's GITHUB_TOKEN, so +# this workflow reads a secret `MIRROR_PUSH_TOKEN` — a fine-grained PAT or GitHub App +# installation token with contents:write on the mirror and nothing else. It has to be +# created before the first release; see CONTRIBUTING.md. +# +# RE-RUN SAFETY: nothing is pushed until every pre-flight check passes, and each +# publishing step checks for its own artifact first (the mirror's tag, this repo's tag, +# the GitHub release, the version on Packagist) and skips if it is already there. So a +# run that failed part-way through is completed by dispatching the same version again. +# The pre-flight allows an existing tag only when it already points at the commit being +# released; a version tagged from a different commit is always a failure. +# +# The mirror gets the full `main` history, not a squash, so its commit SHAs match this +# repository's and a mirror tag can be checked against a local commit. Packagist dist +# zipballs exclude submodule contents anyway, and `.gitattributes` export-ignore drops +# `tests/`, `ably-common/`, `.github/` and `scripts/` from the archive consumers install. + +on: + workflow_dispatch: + inputs: + version: + description: "Version to release, e.g. 2.0.0 — must match Defaults::LIB_VERSION and the top CHANGELOG.md heading" + required: true + +permissions: {} + +env: + MIRROR_REPO: ably/ably-pubsub-php-dist + PACKAGE_NAME: ably/pubsub-server + +jobs: + release: + runs-on: ubuntu-latest + permissions: + # Needed for the annotated pubsub-server/ tag and the GitHub release in + # this repository. The cross-repository push to the mirror uses MIRROR_PUSH_TOKEN; + # GITHUB_TOKEN cannot reach another repository at all. + contents: write + + env: + RELEASE_VERSION: ${{ github.event.inputs.version }} + + steps: + - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 + with: + # Full history and tags: the pre-flight inspects every tag in the repository, + # and the mirror is pushed the whole history rather than a squash. + fetch-depth: 0 + submodules: 'recursive' + # Credentials are persisted so the tag push to this repository works with the + # job's GITHUB_TOKEN. The mirror remote is configured with its own token. + persist-credentials: true + + - name: Set up PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: '8.3' + ini-values: error_reporting=E_ALL + + - name: Fetch all tags + run: git fetch --tags --force + + - name: 'Pre-flight: nothing is pushed if any of these fail' + run: php scripts/release-preflight.php --version "$RELEASE_VERSION" --mirror "$MIRROR_REPO" + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: 'Pre-flight: unit-level tests' + # The full sandbox matrix already ran on the pull request that merged this + # commit; these are the tests that guard the release-critical invariants — the + # wire agent header, the packaging identity, the version sites and the options. + env: + PROTOCOL: json + run: vendor/bin/phpunit --filter 'HttpTest|PackagingTest|DefaultsTest|ClientOptionsTest' + + - name: Extract the CHANGELOG section for this version + run: | + php <<'PHP' > release-notes.md + /dev/null 2>&1; then + echo "Tag ${RELEASE_VERSION} already exists on ${MIRROR_REPO}, skipping (safe re-run)" + else + # This is the only place a Composer-valid 2.x tag is ever created, and it is + # pushed to the mirror and deleted locally in the same breath. It is never + # pushed to origin: the pre-flight fails the next release if it ever is. + git tag -a "${RELEASE_VERSION}" -m "ably/pubsub-server ${RELEASE_VERSION}" "${sha}" + git push mirror "refs/tags/${RELEASE_VERSION}" + git tag -d "${RELEASE_VERSION}" + echo "Tagged ${RELEASE_VERSION} on ${MIRROR_REPO}" + fi + + - name: Tag this repository + run: | + set -euo pipefail + tag="pubsub-server/${RELEASE_VERSION}" + + if git ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "Tag ${tag} already exists on origin, skipping (safe re-run)" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "${tag}" -m "ably/pubsub-server ${RELEASE_VERSION}" + git push origin "refs/tags/${tag}" + echo "Tagged ${tag}" + + - name: Create the GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + tag="pubsub-server/${RELEASE_VERSION}" + + if gh release view "${tag}" >/dev/null 2>&1; then + echo "Release ${tag} already exists, skipping (safe re-run)" + exit 0 + fi + + # A pre-release suffix makes it a GitHub pre-release; Composer independently + # treats such a version as non-stable, so `composer require ably/pubsub-server` + # will not resolve it unless the consumer lowers minimum-stability. + prerelease=() + case "${RELEASE_VERSION}" in + *-*) prerelease=(--prerelease) ;; + esac + + gh release create "${tag}" \ + --title "${RELEASE_VERSION}" \ + --notes-file release-notes.md \ + "${prerelease[@]+"${prerelease[@]}"}" + + - name: 'Post-publish: wait for Packagist to serve the version' + run: | + set -euo pipefail + url="https://repo.packagist.org/p2/${PACKAGE_NAME}.json" + + for i in $(seq 1 20); do + if curl -sf "${url}" \ + | php -r 'exit(in_array(getenv("RELEASE_VERSION"), array_column(json_decode(stream_get_contents(STDIN), true)["packages"][getenv("PACKAGE_NAME")] ?? [], "version"), true) ? 0 : 1);' + then + echo "${PACKAGE_NAME} ${RELEASE_VERSION} is live on Packagist" + exit 0 + fi + echo "Waiting for ${PACKAGE_NAME} ${RELEASE_VERSION} on Packagist (${i}/20)..." + sleep 15 + done + + echo "::error::${PACKAGE_NAME} ${RELEASE_VERSION} did not appear on Packagist within 5 minutes." + echo "::error::The tag is on ${MIRROR_REPO}, so the release itself is done — what failed is" + echo "::error::Packagist picking it up. Check that the mirror has the Packagist webhook" + echo "::error::(https://packagist.org/api/github?username=ably) and that ${PACKAGE_NAME} is" + echo "::error::registered against the mirror on packagist.org (plan.md step 15c). Re-running" + echo "::error::this workflow at the same version skips straight back to this check." + exit 1 From 88758ffa303242bdb5ccb2387cf140d326e6f499 Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 9 Sep 2026 11:24:53 +0100 Subject: [PATCH 3/5] CI: run the release pre-flight in dry-run mode on every PR The gate that blocks a release and the gate that runs continuously are now the same script, so version-site drift and a stray Composer-valid 2.x tag surface at pull-request time rather than at dispatch time. The job checks out full history because tags are what the 2.x guard inspects; a shallow checkout would let it pass by knowing nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/check.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 2cbf4f3..c520485 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -49,3 +49,35 @@ jobs: env: PROTOCOL: ${{ matrix.protocol }} run: composer run-script test + + # Runs the release pre-flight in dry-run mode on every pull request and push, so the + # checks that gate a release are the same checks that run continuously. + # + # A dry run has no authoritative version, so Defaults::LIB_VERSION and the top + # CHANGELOG.md heading only have to agree with each other rather than with a + # dispatched input, and the remote checks (origin's tags, the mirror's tags) are + # skipped. What still runs is the guard that matters most: no Composer-valid tag at + # or above 2.0.0 may exist in this repository, because the legacy ably/ably-php + # Packagist package indexes every Composer-valid tag here regardless of the name in + # its composer.json. See the header of release.yml. + release-dry-run: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 + with: + # Tags are what the 2.x guard inspects, so a shallow tagless checkout would + # let this job pass by knowing nothing. + fetch-depth: 0 + persist-credentials: false + + - name: Set up PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: '8.3' + ini-values: error_reporting=E_ALL + + - name: Release pre-flight (dry run) + run: php scripts/release-preflight.php --dry-run From 396d2b99dc084b1692425b391d8931d1d8a572ca Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 9 Sep 2026 11:26:18 +0100 Subject: [PATCH 4/5] CONTRIBUTING: document the release process and the tag rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file had no release section at all; publishing was manual and tag-driven. Adds the new flow (bump the two version sites in a PR, merge, dispatch release.yml), what the pre-flight checks, the mirror and the three things it needs that live outside this repository, and — at length, because it is the one mistake that cannot be undone — why a plain 2.x.y tag must never be pushed here and why 1.x maintenance tags stay plain. Co-Authored-By: Claude Opus 5 (1M context) --- CONTRIBUTING.md | 118 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index def08b9..23698b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,4 +5,120 @@ 3. Commit your changes (`git commit -am 'Add some feature'`) 4. Ensure you have added suitable tests and the test suite is passing (run `vendor/bin/phpunit`) 4. Push to the branch (`git push origin my-new-feature`) -5. Create a new Pull Request \ No newline at end of file +5. Create a new Pull Request + +## Release process + +Releases are automated. There is no manual tagging step, and there must not be — +see [Why a plain `2.x.y` tag must never be pushed here](#why-a-plain-2xy-tag-must-never-be-pushed-here). + +1. Open a pull request that bumps `Defaults::LIB_VERSION` in `src/Defaults.php` and + adds the release's section to `CHANGELOG.md`. Those two are the only version sites; + `composer.json` carries no `version` field, because Packagist derives versions from + git tags. +2. Merge it. +3. Run the **Release** workflow (`.github/workflows/release.yml`) from the Actions tab + against the merged commit, with the version as its only input — `2.0.0`, or + `2.0.0-rc1` for a pre-release. Composer treats a pre-release suffix as non-stable, so + `composer require ably/pubsub-server` will not resolve it unless the consumer lowers + `minimum-stability`. + +You can run the same checks the workflow will run, before dispatching it: + +```sh +php scripts/release-preflight.php --version 2.0.0 +php scripts/release-preflight.php --version 2.0.0 --skip-remote # no network +``` + +### What the pre-flight checks + +Nothing is pushed anywhere until all of these pass, and it reports every failure +rather than the first one: + +- the version input is `X.Y.Z` or `X.Y.Z-suffix`; +- it equals `Defaults::LIB_VERSION`; +- it equals the version in the top `## [x.y.z]` heading of `CHANGELOG.md`; +- `composer.json`'s `name` is `ably/pubsub-server` — this is what makes the copy of + this workflow on `main` inert while the split still lives on `integration/v2`, and + what stops anyone releasing the legacy layout through it; +- `composer validate --strict` passes; +- the tag `pubsub-server/` does not already exist here, and the plain + `` tag does not already exist on the mirror — unless the existing tag + already points at the commit being released, which is a re-run; +- **no Composer-valid tag at or above `2.0.0` exists in this repository.** + +The same script runs in dry-run mode as the `release-dry-run` job on every pull +request, where there is no authoritative version, so the version sites only have to +agree with each other. The 2.x-tag guard runs there too. + +### What the release workflow does + +1. Runs the pre-flight, then `HttpTest`, `PackagingTest`, `DefaultsTest` and + `ClientOptionsTest`. The full 8.1–8.5 × JSON/msgpack sandbox matrix already ran on + the pull request. +2. Pushes the release commit to the distribution mirror's `main` and creates the + annotated tag `` there. This is the only place a plain version tag of the + new package ever exists. +3. Creates the annotated tag `pubsub-server/` here and a GitHub release on it, + with that version's `CHANGELOG.md` section as the body, marked as a pre-release when + the version carries a suffix. +4. Polls `https://repo.packagist.org/p2/ably/pubsub-server.json` until the version + appears, and fails loudly if it does not. That is the automated proof that the + mirror's Packagist webhook fired. + +Each of those steps checks for its own artifact first and skips it if it is already +there, so a run that failed part-way through is completed by dispatching the same +version again at the same commit. + +### The distribution mirror + +`ably/pubsub-server` is published from **`ably/ably-pubsub-php-dist`**, a read-only +distribution mirror. Development, issues and pull requests happen here and only here; +the mirror never accepts either, and its README says so. It is a build artifact that +happens to be a git repository. + +Operating it needs three things that live outside this repository: + +- **The mirror repository itself.** It receives the full `main` history, so its commit + SHAs match this repository's. +- **A `MIRROR_PUSH_TOKEN` secret here.** A fine-grained personal access token or a + GitHub App installation token with `contents: write` on the mirror and nothing else. + A workflow's own `GITHUB_TOKEN` cannot reach another repository at all, so without + this secret the release fails at the mirror push with an explicit error. +- **The Packagist webhook on the mirror** (`https://packagist.org/api/github?username=ably`, + the same hook this repository has), plus `ably/pubsub-server` registered on + packagist.org against the mirror's URL. Without the webhook the release succeeds and + the Packagist poll fails, which is the intended failure mode: loud and recoverable. + +### Why a plain `2.x.y` tag must never be pushed here + +A Packagist package indexes **every** Composer-valid tag of the repository it is bound +to, whatever that tag's `composer.json` says. Composer's `VcsRepository::preProcess` +deliberately overwrites each version's `name` with the default branch's name, so that +a renamed package's old tags stay installable, and Packagist's updater then stamps its +own package name over every version. There is no name-based filtering of versions +anywhere in that path. + +The legacy `ably/ably-php` package is still bound to this repository, and has to stay +bound to it: its download count puts it behind Packagist's popular-package protection, +so its URL cannot be moved to another repository without Packagist support, and a +repository-ID change would freeze it outright. + +So a plain `2.0.0` tag pushed here would become `ably/ably-php` version `2.0.0` — a +package with a different name, a different namespace and a different PHP floor, served +as the latest release to every consumer with `ably/ably-php: *` or `>=1.1`. They would +upgrade into an install that does not load. Deleting the tag afterwards does not undo +it; the Packagist version has to be pulled by hand by a maintainer. + +Hence the split: plain version tags of the new package exist only on the mirror, and +this repository only ever carries `pubsub-server/` tags, which Composer skips +as invalid version names. The pre-flight fails on any Composer-valid tag at or above +`2.0.0` found here, and the `release-dry-run` job checks the same thing on every pull +request — but a tag is cheap to create and those checks only run afterwards, so the +rule itself is the real protection. + +**1.x maintenance releases are the exception, and they stay plain.** Releases from +`maintenance/1.x` are tagged `1.1.13`, `1.1.14` and so on, exactly as they always have +been, because `ably/ably-php` is *supposed* to index them. That package is bound to +this repository for the whole of its one-year maintenance window, and plain 1.x tags +here are how it gets its releases. Only tags at or above `2.0.0` are forbidden. \ No newline at end of file From a40e898cca5c0aad5c5230d9ccd696c956f3a9c8 Mon Sep 17 00:00:00 2001 From: umair Date: Wed, 9 Sep 2026 11:28:14 +0100 Subject: [PATCH 5/5] release.yml: configure the git identity before any tag is created Both the mirror's plain tag and this repository's namespaced tag are annotated, and an annotated tag needs a tagger identity: `git tag -a` fails with "Committer identity unknown" on a runner, which has none by default. The identity was configured in the step that tags this repository, which is after the step that tags the mirror. Configured once, right after the fetch, so it is in place before either. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ae447e..01b6ad0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,8 +98,15 @@ jobs: php-version: '8.3' ini-values: error_reporting=E_ALL - - name: Fetch all tags - run: git fetch --tags --force + - name: Fetch all tags and configure git + # An annotated tag needs a tagger identity, and the runner has none by default. + # Both the mirror tag and this repository's tag are annotated, so this has to + # happen before either is created. + run: | + set -euo pipefail + git fetch --tags --force + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: 'Pre-flight: nothing is pushed if any of these fail' run: php scripts/release-preflight.php --version "$RELEASE_VERSION" --mirror "$MIRROR_REPO" @@ -183,8 +190,6 @@ jobs: exit 0 fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git tag -a "${tag}" -m "ably/pubsub-server ${RELEASE_VERSION}" git push origin "refs/tags/${tag}" echo "Tagged ${tag}"