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"