Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1d34ec3
fix(scaffolds): harden generation and tooling
Adi-ty Oct 1, 2026
1866241
feat(scaffolds)!: target rtcamp/wp-primitives ^2.0
Adi-ty Oct 1, 2026
e1e7331
ci: lint and test every pull request and push to main
aryanjasala Oct 1, 2026
182a9f1
ci(release): repair diverged mirrors and narrow secret and token scope
aryanjasala Oct 1, 2026
dd2d79f
chore(composer): add Packagist metadata to wp-phpcs and wp-phpstan
aryanjasala Oct 1, 2026
31323d9
fix(detect-changes): add --strict and randomise the GitHub output del…
aryanjasala Oct 1, 2026
a540b21
fix(scaffolds): install the @rtcamp config packages from git, not npm
aryanjasala Oct 1, 2026
e439903
test(features): cover the features CLI end to end and fix two report …
aryanjasala Oct 1, 2026
5a2533c
test(add): cover the human report sections and the interactive wizard
aryanjasala Oct 1, 2026
62be651
test(init): cover the FeatureApi helpers skeleton feature hooks rely on
aryanjasala Oct 1, 2026
b076fe2
test: cover manage-mode flags, list CLI errors and the Tailwind webpa…
aryanjasala Oct 1, 2026
12fcc2b
Merge branch 'main' into chore/go-live-fixes
AnuragVasanwala Oct 1, 2026
bb39efb
docs(changelog): restore the wp/cli description escaping note
AnuragVasanwala Oct 1, 2026
4de6c16
docs(examples): install @rtcamp/eslint-config at ^1.0.0
AnuragVasanwala Oct 1, 2026
dcc8deb
Merge branch 'main' into chore/go-live-fixes
AnuragVasanwala Oct 1, 2026
3f2e1ee
fix(ci): export DetectChangesError from @rtcamp/wp-tooling/ci
aryanjasala Oct 1, 2026
28dd2bf
docs(changelog): separate the Unreleased sections
aryanjasala Oct 1, 2026
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
90 changes: 90 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: CI

# Lints and tests every pull request and every push to main.
#
# Until this workflow existed nothing ran the suites on a PR, while every merge to
# main is split straight onto the npm/* branches that consumers install from, so a
# broken merge reached new installs with no gate in between.
#
# Node: every npm workspace's lint + Jest suite with coverage (each workspace's
# jest.config.js holds its own coverageThreshold), on the engines floor and the
# current LTS.
#
# PHP: each composer-packages/* package is installed and tested standalone, the
# way it ships after the subtree split, on the consumer floor (8.2) and newer.

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
node:
name: Node ${{ matrix.node }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# 22.19 is the engines floor of every package; 24 is the current LTS.
node: ["22.19", "24"]
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}
cache: npm

- name: Install workspaces
run: npm ci --no-audit --no-fund

- name: Lint
run: npm run lint

- name: Test with coverage
run: npm run test:coverage

php:
name: PHP ${{ matrix.php }} / wp-${{ matrix.package }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ["8.2", "8.3", "8.4"]
package: [phpcs, phpstan]
defaults:
run:
working-directory: composer-packages/${{ matrix.package }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: ${{ matrix.php }}
coverage: none
tools: composer:v2

- name: Validate composer.json
run: composer validate --strict --no-check-lock

- name: Install the package standalone
run: composer install --no-interaction --no-progress --prefer-dist

- name: Test
run: composer test
42 changes: 37 additions & 5 deletions .github/workflows/release-php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ name: Release Composer Packages
# can serve rtcamp/wp-phpcs and rtcamp/wp-phpstan from their own repositories.
# Fired by the tag that `vendor/bin/monorepo-builder release` pushes.
#
# splitsh.json is the single source of truth for mirror repo -> subdirectory;
# to add or remove a package, edit that file, never this workflow.
# 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
# step's env below (secrets are referenced by name so the job only receives
# the deploy keys, never the whole secrets context).
#
# Each mirror is pushed with its own SSH deploy key (repo secret
# SPLIT_KEY_<NAME>, "Allow write access" required) rather than a PAT bound to
Expand All @@ -29,6 +31,11 @@ on:
type: boolean
required: false
default: false
resync_branch:
description: "Only force the mirror branch back onto the split (repairs a diverged mirror); pushes no tag"
type: boolean
required: false
default: false

# Pushes to the mirrors must not race each other.
concurrency:
Expand Down Expand Up @@ -159,19 +166,30 @@ jobs:
- name: Split ${{ matrix.package_directory }} → ${{ matrix.mirror_repo }}
shell: bash
env:
SSH_KEY: ${{ secrets[matrix.secret_name] }}
# Named explicitly: a dynamic secrets[...] lookup would hand the job the
# whole secrets context. A new mirror's key has to be listed here.
SPLIT_KEY_PHPCS: ${{ secrets.SPLIT_KEY_PHPCS }}
SPLIT_KEY_PHPSTAN: ${{ secrets.SPLIT_KEY_PHPSTAN }}
SECRET_NAME: ${{ matrix.secret_name }}
PKG_PATH: ${{ matrix.package_directory }}
ORG: ${{ needs.build-matrix.outputs.organization }}
REPO: ${{ matrix.mirror_repo }}
BRANCH: ${{ needs.build-matrix.outputs.branch }}
TAG: ${{ needs.build-matrix.outputs.tag }}
FORCE_PUSH: ${{ github.event_name == 'workflow_dispatch' && inputs.force_push }}
RESYNC_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.resync_branch }}
run: |
set -euo pipefail

# The name comes from splitsh.json, so check its shape before the
# indirect expansion reads a variable by that name.
if [[ ! "$SECRET_NAME" =~ ^SPLIT_KEY_[A-Z0-9_]+$ ]]; then
echo "::error::Refusing deploy-key secret name '$SECRET_NAME': it must look like SPLIT_KEY_<NAME>."
exit 1
fi
SSH_KEY="${!SECRET_NAME:-}"
if [[ -z "$SSH_KEY" ]]; then
echo "::error::No deploy key in $SECRET_NAME. Add the private half of ${ORG}/${REPO}'s write-enabled deploy key as that repository secret."
echo "::error::No deploy key in $SECRET_NAME. Add the private half of ${ORG}/${REPO}'s write-enabled deploy key as that repository secret, and list it in this step's env."
exit 1
fi

Expand Down Expand Up @@ -215,7 +233,21 @@ jobs:
fi

remote="git@github.com:$ORG/$REPO.git"
"${push[@]}" "$remote" "$split_sha:refs/heads/$BRANCH"

# Repair mode: put the mirror branch back onto the split and stop, so a
# mirror that something else committed to (an org bot PR, a hand edit)
# can be fixed without minting a tag that Packagist would publish.
if [[ "$RESYNC_BRANCH" == "true" ]]; then
echo "::warning::resync_branch is on: $ORG/$REPO $BRANCH is being forced onto the split; no tag is pushed."
git push --force "$remote" "$split_sha:refs/heads/$BRANCH"
echo "- **$ORG/$REPO**: \`$BRANCH\` resynced to \`$split_sha\` (no tag)" >> "$GITHUB_STEP_SUMMARY"
exit 0
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."
exit 1
fi
# Packagist versions a package from its tags, so this push is not optional.
"${push[@]}" "$remote" "$split_sha:refs/tags/$TAG"

Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/split-npm-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ on:
- '.github/workflows/split-npm-packages.yml'
workflow_dispatch:

# Pushes branches to this repository; nothing else is needed.
# Read-only by default; only the split job, which pushes the branches, gets write.
permissions:
contents: write
contents: read

# Branch pushes must not race each other.
concurrency:
Expand Down Expand Up @@ -113,11 +113,16 @@ jobs:
name: Split ${{ matrix.package_name }}
needs: build-matrix
runs-on: ubuntu-latest
# Pushes the npm/* branches of this repository; nothing else is needed.
permissions:
contents: write
strategy:
fail-fast: false # one bad package must not hide the others
matrix: ${{ fromJson(needs.build-matrix.outputs.matrix) }}
steps:
- name: Checkout monorepo (full history)
# The persisted GITHUB_TOKEN is what the push below authenticates with, and
# this job uploads no artifacts, so there is nothing for it to leak into.
- name: Checkout monorepo (full history) # zizmor: ignore[artipacked]
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # the split walks every commit that touched the subtree
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ composer install # install s
- `node-packages/tailwind-config/` — `@rtcamp/tailwind-config`, Tailwind v4 PostCSS config + `theme.json` webpack plugin
- `composer-packages/phpcs/` — `rtcamp/wp-phpcs`, PHP_CodeSniffer standards (`rtCampWP`, `rtCampWP-Basic`)
- `composer-packages/phpstan/` — `rtcamp/wp-phpstan`, shared PHPStan baseline for WordPress projects
- `.github/workflows/` — the two subtree-split release workflows; no lint/test CI runs here
- `.github/workflows/` holds `ci.yml` (lint + tests on every PR and push to main) and the two subtree-split release workflows

## Progressive discovery

Expand Down Expand Up @@ -53,7 +53,7 @@ For full release-mechanism details, see `.github/workflows/release-php.yml` and
- 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 install @rtcamp/wp-tooling` does not resolve from the public npm registry today; the current install path is a git URL (`#npm/wp-tooling`). Don't assume a registry publish exists yet — see Architectural decisions above.
- 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.
- No CI workflow lints or tests on every push — `.github/workflows/` only holds the two release/split workflows above. Run the Code quality commands locally before opening a PR.
- `ci.yml` runs every workspace lint, every Jest suite with its coverage threshold (Node 22.19 and 24), and each `composer-packages/*` suite installed standalone (PHP 8.2, 8.3 and 8.4) on every PR and push to main. A red CI blocks the merge that would otherwise ship straight onto the `npm/*` branches.
- `.vscode/extensions.json` only recommends extensions from verified publishers (Microsoft, GitHub, Red Hat, EditorConfig Foundation) — don't add others, regardless of popularity.

## PR instructions
Expand Down
6 changes: 6 additions & 0 deletions composer-packages/phpcs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- `homepage`, `authors` and `support` metadata in `composer.json`, so the Packagist
page links to the monorepo for issues and source (the mirror repository is a
read-only split).

### Changed

- README: a header with the license, PHP and tool-version badges, and a note that the
Expand Down
11 changes: 11 additions & 0 deletions composer-packages/phpcs/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@
"WordPress",
"static analysis"
],
"homepage": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpcs",
"authors": [
{
"name": "rtCamp",
"homepage": "https://rtcamp.com"
}
],
"support": {
"issues": "https://github.com/rtCamp/wp-tooling/issues",
"source": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpcs"
},
"require": {
"php": ">=8.2",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
Expand Down
6 changes: 6 additions & 0 deletions composer-packages/phpstan/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- `homepage`, `authors` and `support` metadata in `composer.json`, so the Packagist
page links to the monorepo for issues and source (the mirror repository is a
read-only split).

### Changed

- README: a header with the license, PHP and tool-version badges, and a note that the
Expand Down
11 changes: 11 additions & 0 deletions composer-packages/phpstan/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@
"wordpress",
"rtcamp"
],
"homepage": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpstan",
"authors": [
{
"name": "rtCamp",
"homepage": "https://rtcamp.com"
}
],
"support": {
"issues": "https://github.com/rtCamp/wp-tooling/issues",
"source": "https://github.com/rtCamp/wp-tooling/tree/main/composer-packages/phpstan"
},
"require": {
"php": ">=8.2",
"phpstan/phpstan": "^2.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,92 @@ describe('GenerateTailwindThemePlugin.generate()', () => {
);
});
});

describe('GenerateTailwindThemePlugin.apply()', () => {
/**
* A webpack compiler double: records each hook's tapped callback.
*
* @return {Object} `{ compiler, taps }`.
*/
const fakeCompiler = () => {
const taps = {};
const hook = (name) => ({
tap: (pluginName, fn) => {
taps[name] = { pluginName, fn };
},
});
return {
taps,
compiler: {
hooks: {
run: hook('run'),
watchRun: hook('watchRun'),
afterEmit: hook('afterEmit'),
},
},
};
};

let plugin;
let generate;
const themeJsonPath = path.join(os.tmpdir(), 'apply-test', 'theme.json');

beforeEach(() => {
plugin = new GenerateTailwindThemePlugin({
themeJson: themeJsonPath,
tailwindCss: path.join(os.tmpdir(), 'apply-test', 'tailwind.css'),
});
generate = jest.spyOn(plugin, 'generate').mockImplementation(() => {});
});

afterEach(() => jest.restoreAllMocks());

test('taps run, watchRun and afterEmit under the plugin name', () => {
const { compiler, taps } = fakeCompiler();
plugin.apply(compiler);
expect(Object.keys(taps).sort()).toEqual([
'afterEmit',
'run',
'watchRun',
]);
for (const tap of Object.values(taps)) {
expect(tap.pluginName).toBe('GenerateTailwindThemePlugin');
}
});

test('a single build generates once', () => {
const { compiler, taps } = fakeCompiler();
plugin.apply(compiler);
taps.run.fn();
expect(generate).toHaveBeenCalledTimes(1);
});

test('watch mode generates on the first run and when theme.json changed', () => {
const { compiler, taps } = fakeCompiler();
plugin.apply(compiler);

taps.watchRun.fn({ modifiedFiles: undefined });
taps.watchRun.fn({ modifiedFiles: new Set([themeJsonPath]) });

expect(generate).toHaveBeenCalledTimes(2);
});

test('watch mode skips rebuilds that did not touch theme.json', () => {
const { compiler, taps } = fakeCompiler();
plugin.apply(compiler);

taps.watchRun.fn({ modifiedFiles: new Set(['/src/other.css']) });

expect(generate).not.toHaveBeenCalled();
});

test('every emit re-registers theme.json as a file dependency', () => {
const { compiler, taps } = fakeCompiler();
plugin.apply(compiler);
const compilation = { fileDependencies: new Set() };

taps.afterEmit.fn(compilation);

expect(compilation.fileDependencies.has(themeJsonPath)).toBe(true);
});
});
Loading
Loading