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
234 changes: 234 additions & 0 deletions .github/workflows/publish-npm-packages.yml
Original file line number Diff line number Diff line change
@@ -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/<dir>`)
# 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"
9 changes: 6 additions & 3 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/` 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

Expand All @@ -38,20 +38,23 @@ 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=<dir>` 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/<dirname>` (public access, set in its `publishConfig`), with its own version. 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.
- **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`.
- **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

- Composer packages have no `version` field in `composer.json` — they version via git tag at the subtree-split step, not a manifest bump. Only their `CHANGELOG.md` gets a heading cut.
- 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/<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`).
- 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.
- `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.
Expand Down
2 changes: 1 addition & 1 deletion node-packages/wp-tooling/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading