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/.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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..01b6ad0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,244 @@ +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 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" + + - 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 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 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 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); +}