#83033·gutenberg

Retire `bin/`: move the last four files into the workspaces that own them

Author: manzoorwanijkCreated Sep 16, 2026Updated Sep 18, 2026
Labels[Status] In Progress

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.

json
// tools/monorepo/tsconfig/node.base.json, extended by all three
{
	"extends": "./dev.base.json",
	"compilerOptions": {
		"allowImportingTsExtensions": true,
		"erasableSyntaxOnly": true,
		"types": [ "node" ]
	}
}
json
// 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.yml triggers on push to wp/** (line 9), and GitHub runs the workflow file from the pushed ref, so it calls that branch's own script. Same for unit-test.yml, static-checks.yml, end2end-test.yml, create-block.yml and publish-npm-packages.yml.
  • performance.yml's prepare job 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.1 and release/23.9 have no bin/plugin-files.txt and package fine.
  • cherry-pick-wp-release.yml is label-gated and pure git. It never invokes bin/.

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.sh to test/unit/scripts/run-date-tests.mts - #83066
  • Move bin/list-experimental-api-matches.sh to tools/monorepo/scripts/list-experimental-apis.mts - #83081
  • Move bin/build-plugin-zip.sh and bin/plugin-files.txt into tools/release - #83087

How the PRs stay independent

  • Each PR deletes only the bin/ files it replaces. There is no "delete bin/" 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:105 reads bin/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.json gets one script line each and root tsconfig.json one project reference each; whichever lands first also adds tools/monorepo/tsconfig/node.base.json. Separate lines or new files throughout, so rebases are trivial.
  • Only the third touches package-lock.json, adding adm-zip to tools/release. On a rebase, regenerate it rather than resolving by hand: reset to the rebased base, remove node_modules, reinstall with the npm major devEngines.packageManager.version pins (>=11.16.0), which is what .github/setup-npm uses.

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:

  1. chdir to the repo root, resolved from import.meta.url, not process.cwd().
  2. With NO_CHECKS unset: refuse a dirty tree (git diff --exit-code, then --cached), then git clean -xdf --dry-run, print the list, prompt [y]es/[N]o via node:readline/promises, git clean -xdf on yes.
  3. npm cache verify, npm ci, npm run build.
  4. When IS_WORDPRESS_CORE === 'true', delete every packages/icons/src/library/*.svg whose manifest.json entry has no public property (99 of 341 carry one), printing each deletion.
  5. Create gutenberg.zip from the plugin-files.mts globs.
  6. Print the deleted icons (git diff --name-only --diff-filter=D -- packages/icons/src) and git 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-entries means no directory records. Confirm adm-zip adds none for lib, build and build-module.
  • zip -r descends into dotfiles; fast-glob skips them unless dot: true.
  • Expand bare directory entries as <dir>/** with onlyFiles: true.
  • adm-zip buffers 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.zip on trunk and on the branch, then diff unzip -l output and per-entry checksums. Repeat with IS_WORDPRESS_CORE=true, confirming the same 99 icons survive and git status is clean after.
  • node tools/release/resolve-performance-branches.mjs gives the same pluginFiles and buildKey as trunk, and npm run test:unit -- tools/release passes.
  • build-plugin-zip.yml and performance.yml both pass on the branch, since each has CI-only paths.

Nice to Have

  • A --skip-install flag on build-plugin-zip.mts, so CI does not repeat npm ci after .github/setup-npm has already installed

Open Questions

  • Keep list-experimental-api-matches at all, given it has had no caller for years?
  • If adm-zip parity proves fiddly, is shelling out to zip an acceptable interim step? It keeps one undeclared binary.

Related

  • The npx replacement and the rule it set
  • #75041 - the workspace conversion that emptied the rest of bin/, closed as completed
  • #78665 - bin/test-create-block.sh to tools/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