From 95e8ea3602b429c7c66f8b6000d82b18118a1a45 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Fri, 6 Nov 2026 18:53:11 +0530 Subject: [PATCH] feat(release): release Composer packages separately - Accept rtcamp/@ tags and split only that package - Validate the subtree and composer.json package name - Preserve v tags in mirrors for Packagist --- .github/workflows/release-php.yml | 59 ++++++++++++++++-------- .github/workflows/split-npm-packages.yml | 4 +- AGENTS.md | 4 +- monorepo-builder.php | 9 ++-- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release-php.yml b/.github/workflows/release-php.yml index 7cd68d9..d641457 100644 --- a/.github/workflows/release-php.yml +++ b/.github/workflows/release-php.yml @@ -1,8 +1,10 @@ name: Release Composer Packages -# Splits composer-packages/* out to their standalone mirror repos so Packagist -# can serve rtcamp/wp-phpcs and rtcamp/wp-phpstan from their own repositories. -# Fired by the tag that `vendor/bin/monorepo-builder release` pushes. +# Splits a composer-packages/* package out to its standalone mirror repo so +# Packagist can serve rtcamp/wp-phpcs and rtcamp/wp-phpstan from their own +# repositories. Each package is versioned on its own: pushing the tag +# rtcamp/wp-phpstan@2.0.0 splits composer-packages/phpstan alone and tags its +# mirror v2.0.0, leaving the other mirror untouched. # # splitsh.json is the single source of truth for mirror repo -> subdirectory. # To add a package, add it there and list its deploy-key secret in the split @@ -18,16 +20,18 @@ name: Release Composer Packages on: push: tags: - - "v*" + # @. npm release tags start with "@" + # (@rtcamp/eslint-config@1.1.0), so they never match. + - "rtcamp/*@*" # Smoke-test the split without cutting a real release tag. workflow_dispatch: inputs: test_tag: - description: "Throwaway tag to push to the mirrors (e.g. v0.0.0-test)" + description: "Release-style tag naming the package to split (e.g. rtcamp/wp-phpstan@0.0.0-test pushes v0.0.0-test to the wp-phpstan mirror)" required: true - default: "v0.0.0-test" + default: "rtcamp/wp-phpstan@0.0.0-test" force_push: - description: "Force-push the mirrors (bootstrap an empty mirror, or re-push the same test tag)" + description: "Force-push the mirror (bootstrap an empty mirror, or re-push the same test tag)" type: boolean required: false default: false @@ -56,6 +60,7 @@ jobs: organization: ${{ steps.parse.outputs.organization }} branch: ${{ steps.parse.outputs.branch }} tag: ${{ steps.parse.outputs.tag }} + release_tag: ${{ steps.parse.outputs.release_tag }} steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -67,11 +72,23 @@ jobs: shell: bash env: # workflow_dispatch runs from a branch, so the input has to win or the - # mirrors get tagged "main"; on a tag push ref_name *is* the tag. + # tag would be "main"; on a tag push ref_name *is* the tag. TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.test_tag || github.ref_name }} run: | set -euo pipefail + # The tag names one package: rtcamp/wp-phpstan@2.0.0 releases the + # wp-phpstan subtree and tags its mirror v2.0.0. This also turns away + # a dispatch whose test_tag was emptied or set to a branch name. + release_re='^([a-z0-9._-]+)/([a-z0-9._-]+)@([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$' + if [[ ! "$TAG" =~ $release_re ]]; then + echo "::error::Refusing tag '$TAG': a release tag must look like /@, for example rtcamp/wp-phpstan@2.0.0." + exit 1 + fi + package_name="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + mirror="${BASH_REMATCH[2]}" + mirror_tag="v${BASH_REMATCH[3]}" + if [[ ! -f "$CONFIG_FILE" ]]; then echo "::error::Config file '$CONFIG_FILE' not found (path is relative to the repo root)." exit 1 @@ -86,10 +103,11 @@ jobs: # the object form ({ "prefixes": [{ "from": "..." }] }), and derives # the deploy-key secret: wp-phpcs -> SPLIT_KEY_PHPCS. A "secret" key # on the object form replaces the derived suffix; the gsub keeps a - # future wp-foo-bar from producing an invalid secret name. - if ! matrix=$(jq -c '{ + # future wp-foo-bar from producing an invalid secret name. Only the + # subtree the tag names makes it into the matrix. + if ! matrix=$(jq -c --arg mirror "$mirror" '{ include: [ - (.subtrees // {}) | to_entries[] | { + (.subtrees // {}) | to_entries[] | select(.key == $mirror) | { mirror_repo: .key, package_directory: ( if (.value | type) == "string" then .value @@ -115,14 +133,17 @@ jobs: count=$(jq -r '.include | length' <<<"$matrix") if [[ "$count" -eq 0 ]]; then - echo "::error::No subtrees defined in '$CONFIG_FILE'. Nothing to split." + echo "::error::'$CONFIG_FILE' has no subtree named '$mirror' (from tag '$TAG'). Nothing to split." exit 1 fi - # Guards a dispatch with an emptied-out test_tag, which would otherwise - # fall through to a branch name and tag the mirrors with it. - if [[ "$TAG" != v* ]]; then - echo "::error::Refusing to push '$TAG' to the mirrors — the tag must start with 'v'." + # The subtree is looked up by the package half alone, so compare the + # full name with the package's composer.json: a mistyped vendor fails + # here instead of releasing anyway. + package_directory=$(jq -r '.include[0].package_directory' <<<"$matrix") + actual_name=$(jq -r '.name // empty' "$package_directory/composer.json" 2>/dev/null || true) + if [[ "$actual_name" != "$package_name" ]]; then + echo "::error::Tag '$TAG' names $package_name, but $package_directory/composer.json is named '${actual_name:-}'." exit 1 fi @@ -141,7 +162,8 @@ jobs: echo "matrix=$matrix" echo "organization=$organization" echo "branch=$branch" - echo "tag=$TAG" + echo "tag=$mirror_tag" + echo "release_tag=$TAG" } >> "$GITHUB_OUTPUT" - name: Echo resolved matrix @@ -176,6 +198,7 @@ jobs: REPO: ${{ matrix.mirror_repo }} BRANCH: ${{ needs.build-matrix.outputs.branch }} TAG: ${{ needs.build-matrix.outputs.tag }} + RELEASE_TAG: ${{ needs.build-matrix.outputs.release_tag }} FORCE_PUSH: ${{ github.event_name == 'workflow_dispatch' && inputs.force_push }} RESYNC_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.resync_branch }} run: | @@ -245,7 +268,7 @@ jobs: fi if ! "${push[@]}" "$remote" "$split_sha:refs/heads/$BRANCH"; then - echo "::error::$ORG/$REPO $BRANCH did not fast-forward: something other than this workflow committed to the mirror. Inspect what it holds, then run this workflow by dispatch on the $TAG tag with resync_branch enabled, and re-run this release run." + echo "::error::$ORG/$REPO $BRANCH did not fast-forward: something other than this workflow committed to the mirror. Inspect what it holds, then dispatch this workflow on the $RELEASE_TAG tag with test_tag=$RELEASE_TAG and resync_branch enabled, and re-run this release run." exit 1 fi # Packagist versions a package from its tags, so this push is not optional. diff --git a/.github/workflows/split-npm-packages.yml b/.github/workflows/split-npm-packages.yml index 088815e..67f16fb 100644 --- a/.github/workflows/split-npm-packages.yml +++ b/.github/workflows/split-npm-packages.yml @@ -16,8 +16,8 @@ name: Split npm Packages # No tags are pushed. npm records the resolved commit SHA in package-lock.json, so # installs are reproducible from a branch ref; the branch is only re-read when a # consumer regenerates its lock. Tags would additionally pin the *spec*, but they -# must not start with "v" — release-php.yml triggers on `v*`, and tags share one -# repo-wide namespace with it. +# must not take the rtcamp/@ form — release-php.yml triggers on +# it, and tags share one repo-wide namespace with it. # # The branches are generated. Never commit to them by hand: the next run pushes a # fast-forward from the monorepo and a diverged branch fails the run instead of diff --git a/AGENTS.md b/AGENTS.md index 1daf149..843b1e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ npm test # Jest acro ## Architectural decisions - **Package layering**: `node-packages/*` (npm workspaces, `@rtcamp` scope) and `composer-packages/*` (Composer, `rtcamp` vendor) are independent ecosystems sharing one repo for coordinated development only — nothing internal is shared or imported across that boundary. -- **Composer packages release by subtree split**: a `v*` tag push runs `release-php.yml`, which splits each `composer-packages/*` directory with `git subtree split --prefix=` to its read-only mirror repo (`rtCamp/wp-phpcs`, `rtCamp/wp-phpstan`) and tags it there for Packagist. +- **Composer packages release one at a time by subtree split**: pushing a `rtcamp/@` tag (for example `rtcamp/wp-phpstan@2.0.0`) runs `release-php.yml`, which splits only that package's `composer-packages/*` directory with `git subtree split --prefix=` to its read-only mirror repo (`rtCamp/wp-phpcs` or `rtCamp/wp-phpstan`) and tags it `v` there for Packagist. Each package keeps its own version. - **npm packages publish to the npm registry**: each `node-packages/*` workspace is published to npmjs.com as `@rtcamp/` (public access, set in its `publishConfig`), with its own version. `publish-npm-packages.yml` publishes each workspace whose `package.json` `version` is not on npm yet, when it changes on `main`. It authenticates with npm trusted publishing (no stored token), and a maintainer approves each release in the protected `npm` environment. Every push to `main` also splits each workspace to an `npm/` branch of this repo, which keeps serving git-URL installs for projects that have not moved to registry versions. - **Dependency discipline differs by package type**: `wp-tooling` ships zero runtime dependencies (full banned-package list in its own `AGENTS.md`); the three config packages instead rely on `peerDependencies` — consumers bring their own `eslint`/`stylelint`/`tailwindcss`. - **Prefer official WordPress tooling**: build custom only when no official `@wordpress/*` (or upstream PHPCS/PHPStan) option covers the need, or the official option blocks a hard constraint. `eslint-config` extends `@wordpress/eslint-plugin`, `stylelint-config` extends `@wordpress/stylelint-config`, `wp-phpcs` layers on WPCS/VIPCS/PHPCompatibilityWP/Slevomat, `wp-phpstan` wraps `szepeviktor/phpstan-wordpress`. @@ -52,7 +52,7 @@ For full release-mechanism details, see `.github/workflows/release-php.yml`, `.g - `wp-tooling release:bump` / `release:changelog` are for **consumer** WordPress plugins/themes, not this monorepo. They require a `.php` file with a `Plugin Name:` header at cwd root and throw otherwise. - Subtree-split artifacts — the `npm/` branches and the `wp-phpcs`/`wp-phpstan` mirror repos — are generated. Never hand-edit them; a diverged target fails the next split run instead of being silently rewritten. - npm packages do version in `package.json`: a release is a pull request that bumps `version` and cuts that package's `CHANGELOG.md` heading. Merging it publishes the version once a maintainer approves the `npm` environment. -- Never use a `v*` tag for an npm release: `release-php.yml` fires on every `v*` tag and tags share one namespace, so it would cut a Composer release too. Tag npm releases `@` (for example `@rtcamp/eslint-config@1.1.0`). +- Composer release tags are `rtcamp/@`, one per package; a bare `v*` tag releases nothing (the `v1.0.0` and `v1.0.1` tags date from when both packages shared a version). npm release tags keep the leading `@` (for example `@rtcamp/eslint-config@1.1.0`), so `release-php.yml` never matches them. - The first version of a new npm package is published by hand, because npm only accepts a trusted publisher for a package that already exists. Until then, the workflow skips the package with a warning and `npm install @rtcamp/` does not resolve, so install from the git URL (`#npm/`). The steps are in the header of `publish-npm-packages.yml`. - Write `bin` paths without a leading `./` (`"bin/wp-tooling.js"`), the form `npm pkg fix` writes. `npm publish` auto-corrects `./bin/...` with a warning, and the workflow's preview step fails on any auto-correction. - Every one of the six packages carries its own `LICENSE` file — `git subtree split` only carries history of files *inside* the split directory, so the root `LICENSE` never reaches a mirror repo or split branch. diff --git a/monorepo-builder.php b/monorepo-builder.php index 9d25d6b..804f555 100644 --- a/monorepo-builder.php +++ b/monorepo-builder.php @@ -49,9 +49,10 @@ ], ]); - // Lockstep release pipeline. Tags are pushed here; the actual subtree - // split to satellite repos is driven by .github/workflows/release-php.yml - // (git subtree split, pushed with a per-mirror SSH deploy key). + // Lockstep release pipeline: `monorepo-builder release` tags every package + // with one `v*` version, which .github/workflows/release-php.yml does not + // act on. Releases are one `rtcamp/@` tag per package + // (see AGENTS.md). $mbConfig->workers([ // Pin every package to depend on the same version of its siblings. SetCurrentMutualDependenciesReleaseWorker::class, @@ -65,7 +66,7 @@ // Create the git tag locally. TagVersionReleaseWorker::class, - // Push the tag — this is what triggers the split workflow. + // Push the tag. PushTagReleaseWorker::class, // Bump mutual dependencies to the next dev version (e.g. ^1.1@dev).