Skip to content

Release tooling: publish ably/pubsub-server through a distribution mirror - #225

Draft
umair-ably wants to merge 5 commits into
pubsub-split/restructurefrom
pubsub-split/release-tooling
Draft

Release tooling: publish ably/pubsub-server through a distribution mirror#225
umair-ably wants to merge 5 commits into
pubsub-split/restructurefrom
pubsub-split/release-tooling

Conversation

@umair-ably

@umair-ably umair-ably commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Stacked on #223 — review that first; this diff shows only the release-tooling changes.

Implements plan steps 15, 15b (repo-side half), 15c (repo-side half) and 16 of plan.md — row 2 of the PR stack in step 10b. Applies PDR-091b.

This repository has no release workflow at all today: publishing is manual and tag-driven — bump Defaults::LIB_VERSION, edit the CHANGELOG, push a tag, write a GitHub release, and let the Packagist webhook notice. That is fine for a package whose repository serves exactly one Packagist entry. It stops being fine the moment this repository has to publish ably/pubsub-server while ably/ably-php is still bound to it.

What

File
scripts/release-preflight.php The pre-flight, as one dependency-free PHP script (no Composer autoload, so it runs before composer install and against a tree whose autoloader is broken). Shared by the release workflow, the PR dry-run job and a maintainer at a terminal, so the release gate and the continuous gate cannot drift. Reports every failure rather than the first.
.github/workflows/release.yml workflow_dispatch only, one required version input. permissions: {} at the top, contents: write on the single job. Pre-flight → unit tests → mirror push and plain tag → namespaced tag and GitHub release here → Packagist visibility poll. Third-party actions pinned to full SHAs with version comments, as check.yml does.
.github/workflows/check.yml A release-dry-run job on every PR and push: php scripts/release-preflight.php --dry-run. Full-history checkout, because tags are what the 2.x guard inspects and a shallow checkout would let it pass by knowing nothing.
CONTRIBUTING.md A "Release process" section: the new flow, what the pre-flight checks, the mirror and the three things it needs that live outside this repo, and why a plain 2.x.y tag must never be pushed here. The existing contribution bullets are unchanged.
.gitattributes One line: /scripts export-ignore, so the release tooling is not in the archive consumers install.

README.md, CHANGELOG.md, src/ and tests/ are deliberately untouched — the docs PR is being written in parallel.

Why a distribution mirror and namespaced tags

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 each version's name with the default branch's name — the comment there says it "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 over every version. There is no name-based filtering of versions anywhere in that path. Tags whose name is not a valid version, like pubsub-server/2.0.0, are skipped outright (VcsRepository::validateTag).

ably/ably-php has to stay bound to this repository. Its ~8.7M downloads put it behind Packagist's PopularPackageSafetyValidator, so its URL cannot be moved to a different repository without Packagist support, and a repository-ID change would freeze the package.

Put those two facts together and a plain 2.0.0 tag pushed here becomes ably/ably-php version 2.0.0: a different package name, a different namespace, a different PHP floor, served as the latest release to every consumer with ably/ably-php: * or >=1.1. They upgrade into an install that does not load. Deleting the tag afterwards does not undo it — a maintainer has to pull the version on Packagist by hand.

So the two packages cannot share a repository:

  • ably/ably-php stays here, and keeps indexing plain 1.x tags from maintenance/1.x. Those tags stay plain: that package is supposed to index them.
  • ably/pubsub-server is published from a read-only distribution mirror, ably/ably-pubsub-php-dist (name per plan.md step 1; the repository does not exist yet). The plain <version> tag exists only there. The mirror is a build artifact that happens to be a git repository — it takes the full main history so its SHAs match this repository's, and it never accepts issues or pull requests.
  • This repository only ever carries pubsub-server/<version> tags, which Composer skips as invalid version names.

Why one package, not core + server

This is a deviation from every other SDK in the programme, and it is a decision rather than an omission — PDR-091b2's PHP row already names only ably/pubsub-server. It is argued in full in #223; the short form, and the part that belongs in this PR:

  • PHP is server-only and REST-only. There is no realtime client and no device package, so there is no second side for a core to be shared with.
  • PHP cannot re-export types. There is no export … from; class_alias does not satisfy a type hint written against the aliased name in a way static analysis follows. A core would force consumers to use Ably\PubSub\Core\Models\Message — type-hinting out of a package whose description says "internal".
  • The two Laravel wrappers are server-side by construction, so PDR-091c's reason for depending on core types (not picking a side) does not arise.
  • On Packagist each extra package costs a whole extra repository. Given the indexing behaviour above, a core would mean a second mirror, a subtree split to populate it, and a second Packagist entry to operate — for no consumer benefit.

The guardrails

Each was broken deliberately and the failure observed, rather than only checked in the passing direction.

Guardrail How it was broken What happened
No Composer-valid tag ≥ 2.0.0 in this repo git tag 2.0.0 in the worktree Dry run failed, naming the tag and explaining that ably/ably-php indexes it. Passed again after git tag -d.
Version input matches every version site --version 9.9.9 Failed with both mismatches listed — LIB_VERSION and the CHANGELOG heading — not just the first.
The mirror must exist before a release ran with remote checks enabled Failed on the mirror tag check: Repository not found, with a pointer to plan.md steps 9 and 15c. This is the current state of the world, and it is why nothing can be released yet.
composer.json names ably/pubsub-server (checked by reading) refuses any other name This is what makes the copy of this workflow on main (PR 3) inert against the legacy layout.
Version sites agree with each other on a PR dry run with no --version LIB_VERSION and the CHANGELOG heading are compared to each other, since a PR has no authoritative version.
MIRROR_PUSH_TOKEN is set (asserted in the workflow) Explicit ::error:: naming the secret, the mirror and CONTRIBUTING.md, before any push is attempted.
Packagist actually served the version (asserted in the workflow) 20 × 15s poll of the p2 metadata endpoint, then a ::error:: explaining that the release itself is done and what to check on the mirror.

Two design notes on the pre-flight:

  • An existing tag is not automatically a failure. If pubsub-server/<version> or the mirror's <version> already points at the commit being released, that is a re-run and it is reported as one; if it points at a different commit, it is always a failure. Releasing one version from two commits is never right.
  • Re-run safety is per-step. Each publishing step checks for its own artifact — the mirror's tag, this repo's tag, the GitHub release, the version on Packagist — and skips it. A run that failed part-way through is completed by dispatching the same version again.

The plain 2.x tag is created inside the release job, pushed to the mirror and deleted from the runner's clone in the same step. It is never pushed to origin, and if it ever were, the next release's pre-flight would refuse to run.

Verification

Run locally on PHP 8.5 / Composer 2 in a worktree off pubsub-split/restructure.

  • actionlint .github/workflows/release.yml .github/workflows/check.yml — clean on release.yml. The one remaining finding is pre-existing, from Split Pub/Sub SDK: ably/pubsub-server package, Ably\PubSub namespace, server door #223: check.yml:44 SC2086 on $IGNORE_PLATFORM_REQ, which is deliberately unquoted so an empty matrix value expands to no argument. Not touched here.
  • php -l scripts/release-preflight.php — no syntax errors.
  • php scripts/release-preflight.php --dry-run — exit 0: version sites agree at 2.0.0; composer validate --strict passed; no Composer-valid tag ≥ 2.0.0 (21 tags inspected).
  • php scripts/release-preflight.php --version 2.0.0 --skip-remote — exit 0, with the mirror check reported as skipped rather than silently absent.
  • php scripts/release-preflight.php --version 9.9.9 --skip-remote — exit 1, 2 failures listed.
  • git tag 2.0.0 && php scripts/release-preflight.php --dry-run — exit 1 on the tag guard; exit 0 again after git tag -d 2.0.0.
  • PROTOCOL=json vendor/bin/phpunit --filter 'HttpTest|PackagingTest|DefaultsTest|ClientOptionsTest'OK (29 tests, 86 assertions), the same filter the release job runs.
  • The Packagist poll expression was run against the live metadata endpoint for ably/ably-php: exit 0 for 1.1.12 (present), exit 1 for 9.9.9 (absent). So the poll can distinguish the two, which is the only thing it has to do.
  • The CHANGELOG extraction step was run against the current CHANGELOG.md and produced the 2.0.0 section body alone, stopping at the next ## heading.

The workflow itself cannot be executed end to end until the mirror repository and its token exist, and it is not dispatchable at all until PR 3 puts it on the default branch. Every step that does not need those was exercised.

Commits

Add the release pre-flight script the shared gate
Add release.yml: publish through the distribution mirror the workflow, with the Packagist reasoning in its header
CI: run the release pre-flight in dry-run mode on every PR the release-dry-run job
CONTRIBUTING: document the release process and the tag rule
release.yml: configure the git identity before any tag is created a review pass on my own workflow found it: both tags are annotated, and git tag -a fails with "Committer identity unknown" on a runner. The identity was configured in the step that tags this repository, one step after the step that tags the mirror. Verified against a scratch repo with the global and system git config suppressed.

Not in this PR / admin prerequisites

Nothing here can publish anything until these are done. They are owner and infra tasks, not code (plan.md steps 9 and 15c).

  • Create the mirror repository ably/ably-pubsub-php-dist, with a README saying "read-only distribution mirror — develop and file issues at ably-pubsub-php" and issues and pull requests disabled.
  • Create the MIRROR_PUSH_TOKEN secret on this repository: a fine-grained PAT or GitHub App installation token with contents: write on the mirror and nothing else. GITHUB_TOKEN cannot reach another repository.
  • Add the Packagist webhook to the mirror (https://packagist.org/api/github?username=ably, the same hook this repository has).
  • Register ably/pubsub-server on packagist.org against the mirror's URL after the first dispatch has seeded the mirror's main — the mirror's default branch carries name: ably/pubsub-server from day one, so Packagist derives the right name. Add the SDK-team maintainers. This can happen before the integration branch merges, which is what lets the prerelease be a real Packagist install.
  • PR 3 cherry-picks release.yml to main, so workflow_dispatch is registered while the split still lives on integration/v2. It is inert there: the pre-flight refuses a ref whose composer.json is still ably/ably-php. Gated on the 1.x maintenance branch being cut first (step 24).
  • The docs PR (plan step 19/20) writes the 2.0.0 CHANGELOG body that this workflow will use as the release notes. Today that section is a placeholder, and the extraction step fails on an empty one.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

umair-ably and others added 5 commits September 9, 2026 11:33
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) <noreply@anthropic.com>
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/<version> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant