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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 41 additions & 18 deletions .github/workflows/release-php.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,16 +20,18 @@ name: Release Composer Packages
on:
push:
tags:
- "v*"
# <composer package name>@<version>. 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
Expand Down Expand Up @@ -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
Expand All @@ -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 <vendor>/<package>@<version>, 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
Expand All @@ -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
Expand All @@ -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:-<none>}'."
exit 1
fi

Expand All @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/split-npm-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<package>@<version> 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
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<dir>` 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/<package>@<version>` 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=<dir>` to its read-only mirror repo (`rtCamp/wp-phpcs` or `rtCamp/wp-phpstan`) and tags it `v<version>` 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/<dirname>` (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/<dirname>` 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`.
Expand All @@ -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/<dirname>` 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 `<name>@<version>` (for example `@rtcamp/eslint-config@1.1.0`).
- Composer release tags are `rtcamp/<package>@<version>`, 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/<name>` does not resolve, so install from the git URL (`#npm/<dirname>`). 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.
Expand Down
9 changes: 5 additions & 4 deletions monorepo-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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/<package>@<version>` tag per package
// (see AGENTS.md).
$mbConfig->workers([
// Pin every package to depend on the same version of its siblings.
SetCurrentMutualDependenciesReleaseWorker::class,
Expand All @@ -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).
Expand Down
Loading