diff --git a/.github/workflows/publish-npm-packages.yml b/.github/workflows/publish-npm-packages.yml
new file mode 100644
index 0000000..809525e
--- /dev/null
+++ b/.github/workflows/publish-npm-packages.yml
@@ -0,0 +1,234 @@
+name: Publish npm Packages
+
+# Publishes each node-packages/* workspace whose package.json version is not on
+# npm yet. A release is a PR that bumps "version"; once it merges, the publish
+# job waits for approval in the `npm` environment, then publishes with npm
+# trusted publishing (OIDC, no stored token).
+#
+# One-time setup:
+# - The `npm` environment needs required reviewers, limited to `main`.
+# - A new package is skipped with a warning until an npm org owner publishes
+# its first version by hand (`npm publish --workspace node-packages/
`)
+# and adds a trusted publisher on npmjs.com: rtCamp / wp-tooling /
+# publish-npm-packages.yml / environment `npm` (a mismatch fails with E404).
+
+on:
+ push:
+ branches: [main]
+ paths:
+ # A release always bumps "version", so only manifest changes can publish.
+ - 'node-packages/*/package.json'
+ - '.github/workflows/publish-npm-packages.yml'
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: 'Only preview with `npm publish --dry-run`'
+ type: boolean
+ default: true
+
+permissions: {}
+
+concurrency:
+ group: publish-npm-packages
+ cancel-in-progress: false
+
+# Trusted publishing needs npm 11.5.1+ (.nvmrc's Node ships npm 10). Exact, not
+# a range: every job must run the same npm, and the publish job can start days
+# after the check job's preview.
+env:
+ NPM_VERSION: 11.21.0
+
+jobs:
+ plan:
+ name: Find versions not yet on npm
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ outputs:
+ matrix: ${{ steps.plan.outputs.matrix }}
+ count: ${{ steps.plan.outputs.count }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ # No dependency cache in this workflow: a poisoned cache would run in a release.
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .nvmrc
+ package-manager-cache: false
+
+ - name: Update npm
+ run: npm install --global "npm@${NPM_VERSION}"
+
+ - name: Compare each workspace's version with npm
+ id: plan
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ # npm resolves the root "workspaces" globs; keys are package names.
+ workspaces=$(npm pkg get name version private --workspaces --json)
+ invalid=$(jq -r '[to_entries[] | select(.value.name == null or .value.version == null) | .key] | join(", ")' <<<"$workspaces")
+ if [[ -n "$invalid" ]]; then
+ echo "::error::Each workspace needs both \"name\" and \"version\" in its package.json: $invalid"
+ exit 1
+ fi
+
+ entries=()
+ while IFS=$'\t' read -r name version private; do
+ if [[ "$private" == "true" ]]; then
+ echo "::notice::Skipping $name — marked private."
+ continue
+ fi
+
+ # name@version 404s for a missing package and a missing version
+ # alike, so read the version list. With --json, errors go to stdout.
+ if ! published=$(npm view "$name" versions --json 2>/dev/null); then
+ code=$(jq -r '.error.code? // empty' <<<"$published" 2>/dev/null || true)
+ if [[ "$code" == "E404" ]]; then
+ echo "::warning::$name is not on npm yet. Publish $version by hand once and add this workflow as its trusted publisher (see this workflow's header)."
+ continue
+ fi
+ echo "::error::Could not read $name from npm (${code:-no error code}). Refusing to guess whether $version is published."
+ exit 1
+ fi
+
+ if jq -e --arg v "$version" '(if type == "array" then . else [.] end) | index($v) != null' <<<"$published" >/dev/null; then
+ echo "::notice::$name@$version is already on npm."
+ continue
+ fi
+
+ # Prereleases (1.2.0-beta.1) must not become what `npm install` picks.
+ dist_tag=latest
+ [[ "$version" == *-* ]] && dist_tag=next
+
+ entries+=("$(jq -cn --arg n "$name" --arg v "$version" --arg t "$dist_tag" \
+ '{package_name:$n, version:$v, dist_tag:$t}')")
+ done < <(jq -r 'to_entries[] | [.key, .value.version, (.value.private // false)] | @tsv' <<<"$workspaces")
+
+ if [[ ${#entries[@]} -eq 0 ]]; then
+ matrix='{"include":[]}'
+ else
+ matrix=$(printf '%s\n' "${entries[@]}" | jq -cs '{include: .}')
+ fi
+ echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
+ echo "count=${#entries[@]}" >> "$GITHUB_OUTPUT"
+ jq . <<<"$matrix"
+
+ check:
+ name: Lint, test and preview
+ needs: plan
+ if: needs.plan.outputs.count != '0'
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .nvmrc
+ package-manager-cache: false
+
+ - name: Install workspace dependencies
+ run: npm ci
+
+ - name: Lint and test every workspace
+ run: npm run check
+
+ - name: Update npm
+ run: npm install --global "npm@${NPM_VERSION}"
+
+ - name: Preview each publish
+ shell: bash
+ env:
+ MATRIX: ${{ needs.plan.outputs.matrix }}
+ run: |
+ set -euo pipefail
+
+ failed=0
+ while IFS=$'\t' read -r name version tag; do
+ # --tag only for prereleases: an explicit `--tag latest` skips npm's
+ # refusal to publish below the highest published version.
+ tag_args=()
+ [[ "$tag" != latest ]] && tag_args=(--tag "$tag")
+
+ echo "::group::$name@$version"
+ status=0
+ preview=$(npm publish --workspace "$name" "${tag_args[@]}" --dry-run 2>&1) || status=$?
+ echo "$preview"
+ echo "::endgroup::"
+
+ if [[ $status -ne 0 ]]; then
+ echo "::error::npm would refuse to publish $name@$version. Its output is above."
+ failed=1
+ continue
+ fi
+
+ # npm only warns when it rewrites an invalid manifest field, so the
+ # published metadata would silently differ from the committed one.
+ if grep -q 'auto-corrected some errors' <<<"$preview"; then
+ echo "::error::npm would rewrite $name's package.json while publishing. Run \`npm pkg fix --workspace $name\` and commit the result."
+ failed=1
+ continue
+ fi
+
+ echo "- **$name@$version** — ready to publish as \`$tag\`" >> "$GITHUB_STEP_SUMMARY"
+ done < <(jq -r '.include[] | [.package_name, .version, .dist_tag] | @tsv' <<<"$MATRIX")
+
+ exit "$failed"
+
+ publish:
+ name: Publish ${{ matrix.package_name }}@${{ matrix.version }}
+ needs: [plan, check]
+ if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
+ runs-on: ubuntu-24.04
+ # The approval gate; npm's trusted publisher also only accepts this environment.
+ environment: npm
+ # id-token is exchanged for the publish token. Never install workspace
+ # dependencies here: their install scripts would run with it.
+ permissions:
+ contents: read
+ id-token: write
+ strategy:
+ fail-fast: false # one bad package must not hold back the others
+ matrix: ${{ fromJson(needs.plan.outputs.matrix) }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .nvmrc
+ registry-url: https://registry.npmjs.org
+ package-manager-cache: false
+
+ - name: Update npm
+ run: npm install --global "npm@${NPM_VERSION}"
+
+ - name: Publish ${{ matrix.package_name }}
+ shell: bash
+ env:
+ PKG_NAME: ${{ matrix.package_name }}
+ VERSION: ${{ matrix.version }}
+ DIST_TAG: ${{ matrix.dist_tag }}
+ run: |
+ set -euo pipefail
+
+ # Registry and access come from publishConfig. --tag only for
+ # prereleases, as in the check job's preview.
+ tag_args=()
+ [[ "$DIST_TAG" != latest ]] && tag_args=(--tag "$DIST_TAG")
+ npm publish --workspace "$PKG_NAME" "${tag_args[@]}"
+
+ echo "- **$PKG_NAME@$VERSION** — published as \`$DIST_TAG\`: https://www.npmjs.com/package/$PKG_NAME/v/$VERSION" >> "$GITHUB_STEP_SUMMARY"
diff --git a/AGENTS.md b/AGENTS.md
index c8125fb..1daf149 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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/` holds `ci.yml` (lint + tests on every PR and push to main) and the two subtree-split release workflows
+- `.github/workflows/` holds `ci.yml` (lint + tests on every PR and push to main), the two subtree-split release workflows and the npm publish workflow
## Progressive discovery
@@ -38,12 +38,12 @@ npm test # Jest acro
- **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.
-- **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. 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.
+- **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`.
- **PHP version skew is intentional**: the packages target PHP `>=8.2` (the rtCamp plugin/theme floor) while the monorepo root requires `>=8.4.1` for `symplify/monorepo-builder`. `monorepo-builder validate` will flag this — expected, not a bug.
-For full release-mechanism details, see `.github/workflows/release-php.yml` and `.github/workflows/split-npm-packages.yml`.
+For full release-mechanism details, see `.github/workflows/release-php.yml`, `.github/workflows/split-npm-packages.yml` and `.github/workflows/publish-npm-packages.yml`.
## Common pitfalls
@@ -51,7 +51,10 @@ For full release-mechanism details, see `.github/workflows/release-php.yml` and
- There is no root `composer test` / `composer check` — root `composer.json` has no `scripts` key. Run tests from inside each `composer-packages/*` directory.
- `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`).
+- 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.
- `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.
diff --git a/node-packages/wp-tooling/package.json b/node-packages/wp-tooling/package.json
index 4eefda8..c5fefd9 100644
--- a/node-packages/wp-tooling/package.json
+++ b/node-packages/wp-tooling/package.json
@@ -4,7 +4,7 @@
"version": "2.0.0",
"author": "rtCamp",
"bin": {
- "wp-tooling": "./bin/wp-tooling.js"
+ "wp-tooling": "bin/wp-tooling.js"
},
"bugs": {
"url": "https://github.com/rtCamp/wp-tooling/issues"