Retire `bin/`: move the last four files into the workspaces that own them
Follow-up to #82331 and #75041.
Overall Goal
#82331, #81017, #81137 and Core's Trac 65864 established one rule, now in AGENTS.md: every binary a task needs is declared by the workspace that runs it and installed through the lockfile. Never fetched from the registry at run time, never taken from whatever the machine happens to have, never assumed to be hoisted into the root node_modules.
bin/ is the last place that rule cannot reach. Not because these scripts call npx (none do), but because a shell script in a directory with no package.json has nothing to declare into: it can only reach for what is on PATH. Between them the four remaining files use zip, jq, git, grep, awk, sed, sort, uniq, comm and xargs, none of it lockfile-covered or version-pinned. bin/build-plugin-zip.sh:69-73 hand-rolls a command -v jq check and an "install jq and try again" message, which is a manual version of what npm exec --no does for free.
Giving each script a package.json makes most of that surface disappear: JSON.parse replaces jq, adm-zip and fast-glob replace zip and shell globbing, and Node string handling replaces awk, sed, comm, sort, uniq and xargs. Only git stays, as a child process. It also makes them work under the isolated install strategy (#75814), where a script at the repo root can no longer count on hoisted binaries.
#75041 emptied the rest of bin/ into workspaces and closed as completed in June 2026, leaving these four behind because none belonged to a workspace it was converting. Its step 6.1 is the shape each task below follows: a .sh becomes a Node script in a workspace, gets a workspace script, gets a root script delegating with npm run --workspace ... --, and the workflow calls the root script.
Scope
| File | Lines | Invoked by | Undeclared binaries |
|---|---|---|---|
bin/build-plugin-zip.sh |
111 | build:plugin-zip, .github/workflows/build-plugin-zip.yml:263 |
bash, git, npm, zip, jq, comm, xargs, sed |
bin/plugin-files.txt |
12 | bin/build-plugin-zip.sh:105, tools/release/resolve-performance-branches.mjs:278 |
none (data) |
bin/unit-test-date.sh |
35 | test:unit:date, .github/workflows/unit-test.yml:205 |
bash (arrays, job control) |
bin/list-experimental-api-matches.sh |
65 | nothing | sh, git, grep, xargs, awk, sort, uniq |
Each goes to the workspace that already owns its job:
| From | To |
|---|---|
bin/plugin-files.txt |
tools/release/plugin-files.mts |
bin/build-plugin-zip.sh |
tools/release/build-plugin-zip.mts |
bin/unit-test-date.sh |
test/unit/scripts/run-date-tests.mts |
bin/list-experimental-api-matches.sh |
tools/monorepo/scripts/list-experimental-apis.mts |
tools/release already owns releasing the plugin, already holds resolve-performance-branches.mjs (the file list's other consumer) and publish-to-svn.sh (the step that consumes the ZIP), and already declares fast-glob@^3.2.7. test/unit already owns every other test:unit:* script. tools/monorepo keeps the repo-wide audit.
Nothing needs a performance.yml paths filter change. Line 64 excludes tools/{docs,eslint,monorepo,stylelint,validation}/** and line 56 excludes test/{integration,php,storybook-playwright,unit}/**. tools/release is in neither, so the plugin file list stays performance-relevant where it lands, and the two test and audit scripts correctly do not.
TypeScript, as .mts. Node 24.18 is the floor and type stripping has been default since 22.18, so these run with no flag and no build step. None of the three destinations sets "type": "module", and a plain .ts file there does run but prints a MODULE_TYPELESS_PACKAGE_JSON warning on every invocation ("doesn't parse as CommonJS, reparsing as ES module"). .mts is unambiguously ESM, so it stays silent, and .mjs files import it unchanged: resolve-performance-branches.mjs needs no conversion.
Each workspace scopes include to the new files, so the JavaScript around them never enters the program and tools/release keeps its CommonJS cli.js, config.js and log-performance-results.js untouched.
// tools/monorepo/tsconfig/node.base.json, extended by all three
{
"extends": "./dev.base.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"types": [ "node" ]
}
}// tools/release/tsconfig.json; the other two differ only in `include`
{
"extends": "@wordpress/monorepo-tools/tsconfig/node.base.json",
"include": [ "*.mts" ]
}Each also needs a { "path": ... } entry in the root tsconfig.json, which brings the new files under npm run typecheck. Nothing else: @wordpress/monorepo-tools is a root devDependency, which is how the 54 packages already extending the preset resolve it.
Release branches are unaffected
No wp/* or release/* branch needs updating, and none was updated for the six earlier bin/ moves.
- Every release branch carries its own
bin/, so deleting trunk's copies touches no other ref. - Workflows resolve per-ref.
build-plugin-zip.ymltriggers onpushtowp/**(line 9), and GitHub runs the workflow file from the pushed ref, so it calls that branch's own script. Same forunit-test.yml,static-checks.yml,end2end-test.yml,create-block.ymlandpublish-npm-packages.yml. performance.yml'spreparejob reads the file list from the current ref and passes it to the build matrix as an output, so old refs never read it. Already proven:wp/7.1andrelease/23.9have nobin/plugin-files.txtand package fine.cherry-pick-wp-release.ymlis label-gated and pure git. It never invokesbin/.
The one residual is that a future commit touching tools/release/build-plugin-zip.mts cannot be cherry-picked onto a branch still holding bin/build-plugin-zip.sh. That is already true of every script #75041 moved. No delegating stubs: none of the earlier moves used one.
Tasks
Three PRs, one per destination workspace. None depends on another, and each leaves trunk green on its own.
- Move
bin/unit-test-date.shtotest/unit/scripts/run-date-tests.mts- #83066 - Move
bin/list-experimental-api-matches.shtotools/monorepo/scripts/list-experimental-apis.mts- #83081 - Move
bin/build-plugin-zip.shandbin/plugin-files.txtintotools/release- #83087
How the PRs stay independent
- Each PR deletes only the
bin/files it replaces. There is no "deletebin/" step; git does not track empty directories, so it disappears when the last of the three lands. That is what removes the ordering requirement. - The file list ships with the script that reads it.
bin/build-plugin-zip.sh:105readsbin/plugin-files.txt, so moving the list alone would break the shell script or need a temporary bridge. - Each PR edits its own workspace plus two shared files. Root
package.jsongets one script line each and roottsconfig.jsonone project reference each; whichever lands first also addstools/monorepo/tsconfig/node.base.json. Separate lines or new files throughout, so rebases are trivial. - Only the third touches
package-lock.json, addingadm-ziptotools/release. On a rebase, regenerate it rather than resolving by hand: reset to the rebased base, removenode_modules, reinstall with the npm majordevEngines.packageManager.versionpins (>=11.16.0), which is what.github/setup-npmuses.
1. Date test matrix
A direct port into test/unit/scripts/, next to the .mjs validators already there: spawn the six timezone/locale combinations (EST, GMT, CET by en_US, ja_JP) concurrently with child_process.spawn, each inheriting stdio and carrying TZ, LANG, FK_ENV_TZ, FK_ENV_LANG and FLAKINESS_OUTPUT_DIR=flakiness-report-<tz>-<locale>, running the workspace's own test:unit:vitest script with packages/date <args>. Trailing args must pass through: CI calls npm run test:unit:date -- --maxWorkers="$(nproc)".
Fix a bug rather than porting it. The wait "$pid" || ( echo ...; exit 1 ) at lines 31-34 runs exit 1 in a subshell, so a failing combination does not fail the script, and set -e misses it because the failure is the left side of ||. Collect all six exit codes, print a line per failure, set process.exitCode = 1 if any failed. Flag it in the PR: it may surface an existing failure in unit-test.yml.
Add "test:unit:date": "node scripts/run-date-tests.mts" to test/unit, and point the root script at it: "test:unit:date": "npm run --workspace @wordpress/unit-tests test:unit:date --", matching the eleven sibling test:unit:* entries.
Verification. All six combinations run, flakiness-report-* directories appear, and an intentionally failing test now exits non-zero.
2. Experimental API audit
git ls-files packages lib for the file list, filter to .js|.ts|.jsx|.tsx|.php excluding __tests__, scan for /__experimental\w+/g, map each path to its namespace (lib, else <parts[0]>/<parts[1]>), keep the first namespace each API appears under after sorting, print the same Markdown. Drops awk, sort, uniq and xargs.
Verification. Diff the output against the old script's on trunk.
3. Plugin file list and ZIP build
The riskiest of the three: the output ships to the WordPress.org plugin repository and to wordpress-develop. Archive parity is the acceptance criterion.
The file list. Export it as an array from tools/release/plugin-files.mts. In resolve-performance-branches.mjs, replace the fs.readFileSync( path.join( 'bin', 'plugin-files.txt' ) ) block (lines 277-282) with an import from the sibling module. Keep the joined string byte-identical: computeBuildKey (line 88) hashes it, so any reordering invalidates every cached performance build artifact. Update the comment at performance.yml:204 to name the new path.
The build script, ported step for step:
chdirto the repo root, resolved fromimport.meta.url, notprocess.cwd().- With
NO_CHECKSunset: refuse a dirty tree (git diff --exit-code, then--cached), thengit clean -xdf --dry-run, print the list, prompt[y]es/[N]ovianode:readline/promises,git clean -xdfon yes. npm cache verify,npm ci,npm run build.- When
IS_WORDPRESS_CORE === 'true', delete everypackages/icons/src/library/*.svgwhosemanifest.jsonentry has nopublicproperty (99 of 341 carry one), printing each deletion. - Create
gutenberg.zipfrom theplugin-files.mtsglobs. - Print the deleted icons (
git diff --name-only --diff-filter=D -- packages/icons/src) andgit restore packages/icons/src.
Step 4 drops jq and its command -v guard. Step 5 drops zip in favour of adm-zip (^0.6.0, the version packages/env and packages/scripts pin, so syncpack stays quiet) and the fast-glob tools/release already declares.
Parity details when replacing zip --recurse-paths --no-dir-entries:
- No root folder. Archive paths are repo-root relative, as the shell glob expansion produces them.
--no-dir-entriesmeans no directory records. Confirmadm-zipadds none forlib,buildandbuild-module.zip -rdescends into dotfiles;fast-globskips them unlessdot: true.- Expand bare directory entries as
<dir>/**withonlyFiles: true. adm-zipbuffers in memory. Fine at the current size.
Add "build-plugin-zip": "node ./build-plugin-zip.mts" to tools/release, point the root script at it, and change build-plugin-zip.yml:263 to run: npm run build:plugin-zip with its env block unchanged. tools/release/config.js:34 already declares a buildZipCommand that nothing reads: point it at the new script or delete it. docs/contributors/code/getting-started-with-code-contribution.md:201 says the script "requires bash and php to run", which is no longer true.
Verification.
- Build
gutenberg.zipon trunk and on the branch, then diffunzip -loutput and per-entry checksums. Repeat withIS_WORDPRESS_CORE=true, confirming the same 99 icons survive andgit statusis clean after. node tools/release/resolve-performance-branches.mjsgives the samepluginFilesandbuildKeyas trunk, andnpm run test:unit -- tools/releasepasses.build-plugin-zip.ymlandperformance.ymlboth pass on the branch, since each has CI-only paths.
Nice to Have
- A
--skip-installflag onbuild-plugin-zip.mts, so CI does not repeatnpm ciafter.github/setup-npmhas already installed
Open Questions
- Keep
list-experimental-api-matchesat all, given it has had no caller for years? - If
adm-zipparity proves fiddly, is shelling out tozipan acceptable interim step? It keeps one undeclared binary.
Related
- The
npxreplacement and the rule it set- Trac: Avoid npx when running build and test tooling #65864
- #82331
- #81017
- #81137
- #75041 - the workspace conversion that emptied the rest of
bin/, closed as completed - #78665 -
bin/test-create-block.shtotools/validation, the closest precedent - #75814 - the isolated (
linked) install strategy these scripts have to survive - #72973 - converting these tools to TypeScript, which this advances for three workspaces
Source: WordPress/gutenberg