From d6f515ef67efea042b60cbda5b35d541a82e9b43 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Thu, 17 Sep 2026 20:51:40 +0530 Subject: [PATCH 1/2] Release tools: Move the plugin ZIP build out of bin/ `bin/build-plugin-zip.sh` and `bin/plugin-files.txt` become `tools/release/build-plugin-zip.mts` and `tools/release/plugin-files.mts`, the workspace that already owns releasing the plugin. They are the last two files in `bin/`, so the directory goes with them, and PHPCS stops being pointed at it. A shell script in a directory with no `package.json` has nothing to declare into: it can only reach for what is on `PATH`. `JSON.parse` replaces `jq` and its hand-rolled `command -v` guard, `adm-zip` and the `fast-glob` the workspace already declares replace `zip` and shell globbing, and Node string handling replaces `comm`, `xargs` and `sed`. Only `git` and `npm` stay, as child processes. The file list is now a module. `resolve-performance-branches.mjs` imports it rather than reading it off disk, and the joined string is byte-identical, so `computeBuildKey` hashes to the same value and cached performance build artifacts stay valid. `config.js` has carried a `buildZipCommand` naming the script since the release tooling was written, and nothing has ever read it. Repointing it would only preserve a value no caller consults, so it goes. Where the port deviates from the shell script, it does so deliberately: Nothing outside Node is imported at load time. The release workflow installs npm but never the dependencies, so the script has to reach its own `npm ci` before it can import anything that `npm ci` provides. The archive still goes to a temporary sibling and is renamed once written, which is what `zip` does for a new archive as well as an update, so a failed run leaves the previous archive intact. The buffer is written by Node rather than by adm-zip, whose writer ignores `bytesWritten` and reports a short write, the kind a filling filesystem produces, as a success. A failed build no longer leaves the pruned icons deleted. The shell script exited at the failing `zip` with the restore still ahead of it. Dot files come from an expanded directory only. `zip --recurse-paths` found them there, while Bash never expanded them into an explicit `*.php` or `*.svg`, and the performance workflow still expands the list in a shell. Archive parity was verified in both modes against the script this replaces: the same 2120 entries with matching sizes and CRCs for a plugin build, the same 1878 and the same 99 icons for a WordPress Core build, no directory records in either, and a clean `packages/icons/src` afterwards. `tools/monorepo` declares `"type": "module"`. Its only script is already `.mts`, so nothing there changes, but a plain `.ts` or `.js` added next to it will be ESM rather than warning its way through `MODULE_TYPELESS_PACKAGE_JSON`. --- .github/CODEOWNERS | 1 + .github/workflows/build-plugin-zip.yml | 2 +- .github/workflows/performance.yml | 2 +- bin/build-plugin-zip.sh | 111 ------ bin/plugin-files.txt | 12 - .../getting-started-with-code-contribution.md | 2 +- package-lock.json | 2 + package.json | 2 +- phpcs.xml.dist | 1 - tools/monorepo/package.json | 1 + tools/release/build-plugin-zip.mts | 333 ++++++++++++++++++ tools/release/config.js | 2 - tools/release/package.json | 3 + tools/release/plugin-files.mts | 19 + .../release/resolve-performance-branches.mjs | 10 +- tools/release/tsconfig.json | 5 + tsconfig.json | 1 + 17 files changed, 372 insertions(+), 137 deletions(-) delete mode 100755 bin/build-plugin-zip.sh delete mode 100644 bin/plugin-files.txt create mode 100644 tools/release/build-plugin-zip.mts create mode 100644 tools/release/plugin-files.mts create mode 100644 tools/release/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d53b64289513c9..572c318ffcbe8b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -25,6 +25,7 @@ /packages/interactivity @luisherranz @darerodz # Tooling +# Emptied out by #83033; the entry stays so anything reappearing there gets reviewed. /bin @manzoorwanijk /tools @manzoorwanijk /test/php/gutenberg-coding-standards @anton-vlasenko diff --git a/.github/workflows/build-plugin-zip.yml b/.github/workflows/build-plugin-zip.yml index bcfd0617e5c8cb..27078a7ee977c2 100644 --- a/.github/workflows/build-plugin-zip.yml +++ b/.github/workflows/build-plugin-zip.yml @@ -260,7 +260,7 @@ jobs: run: jq --tab '.wpPlugin.name = "wp"' package.json > package.json.tmp && mv package.json.tmp package.json - name: Build Gutenberg plugin ZIP file - run: ./bin/build-plugin-zip.sh + run: npm run build:plugin-zip env: NO_CHECKS: 'true' IS_GUTENBERG_PLUGIN: ${{ matrix.IS_GUTENBERG_PLUGIN }} diff --git a/.github/workflows/performance.yml b/.github/workflows/performance.yml index 10213756c1a0a4..d8b8c3993360ab 100644 --- a/.github/workflows/performance.yml +++ b/.github/workflows/performance.yml @@ -201,7 +201,7 @@ jobs: if: steps.reused.outputs.ok != 'true' run: npm run build -- --skip-types || npm run build - # The list comes from bin/plugin-files.txt on the tested commit, so older refs + # The list comes from tools/release/plugin-files.mts on the tested commit, so older refs # are packaged the same way. They may lack some of the files. - name: Package plugin if: steps.reused.outputs.ok != 'true' diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh deleted file mode 100755 index 5541d9f83aee75..00000000000000 --- a/bin/build-plugin-zip.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/bin/bash - -# Exit if any command fails. -set -e - -# Change to the expected directory. -cd "$(dirname "$0")" -cd .. - -# Enable nicer messaging for build status. -BLUE_BOLD='\033[1;34m'; -GREEN_BOLD='\033[1;32m'; -RED_BOLD='\033[1;31m'; -YELLOW_BOLD='\033[1;33m'; -COLOR_RESET='\033[0m'; -error () { - echo -e "\n${RED_BOLD}$1${COLOR_RESET}\n" -} -status () { - echo -e "\n${BLUE_BOLD}$1${COLOR_RESET}\n" -} -success () { - echo -e "\n${GREEN_BOLD}$1${COLOR_RESET}\n" -} -warning () { - echo -e "\n${YELLOW_BOLD}$1${COLOR_RESET}\n" -} - -status "💃 Time to build the Gutenberg plugin ZIP file 🕺" - -if [ -z "$NO_CHECKS" ]; then - # Make sure there are no changes in the working tree. Release builds should be - # traceable to a particular commit and reliably reproducible. - changed= - if ! git diff --exit-code > /dev/null; then - changed="file(s) modified" - elif ! git diff --cached --exit-code > /dev/null; then - changed="file(s) staged" - fi - if [ ! -z "$changed" ]; then - git status - error "ERROR: Cannot build plugin zip with dirty working tree. ☝️ - Commit your changes and try again." - exit 1 - fi - - # Do a dry run of the repository reset. Prompting the user for a list of all - # files that will be removed should prevent them from losing important files! - status "Resetting the repository to pristine condition. ✨" - to_clean=$(git clean -xdf --dry-run) - if [ ! -z "$to_clean" ]; then - echo $to_clean - warning "🚨 About to delete everything above! Is this okay? 🚨" - echo -n "[y]es/[N]o: " - read answer - if [ "$answer" != "${answer#[Yy]}" ]; then - # Remove ignored files to reset repository to pristine condition. Previous - # test ensures that changed files abort the plugin build. - status "Cleaning working directory... 🛀" - git clean -xdf - else - error "Fair enough; aborting. Tidy up your repo and try again. 🙂" - exit 1 - fi - fi - - # WordPress Core builds prune non-public icons using jq, which is expected to be - # present on the system running this script. - if [ "$IS_WORDPRESS_CORE" = "true" ] && ! command -v jq > /dev/null 2>&1; then - error "ERROR: jq is required to build for WordPress Core but was not found. 🫥 - Install jq (https://jqlang.org/) and try again." - exit 1 - fi -fi - -# Run the build. -status "Installing dependencies... 📦" -npm cache verify -npm ci -status "Generating build... 👷‍♀️" -npm run build - -# Only including icons shipped to WordPress Core when building for it. An icon is shipped when its -# manifest entry carries a `public` property, whether that property is `true` or `false`. -# -# This runs before creating the archive but after the build so icon collection validation passes as expected. Plugin -# builds keep the full library. -if [ "$IS_WORDPRESS_CORE" = "true" ]; then - status "Pruning non-public icons for WordPress Core... ✂️" - ( - cd packages/icons/src - non_public_icons=$(comm -13 \ - <(jq -r "map(select(.public != null) | .filePath)[]" manifest.json | sort) \ - <(ls library/*.svg)) - echo "$non_public_icons" | sed 's|^| Deleting packages/icons/src/|' - echo "$non_public_icons" | xargs rm - ) -fi - -# Generate the plugin zip file. -status "Creating archive... 🎁" -# shellcheck disable=SC2046 # The list holds globs that must expand. -zip --recurse-paths --no-dir-entries \ - gutenberg.zip \ - $(grep -v '^#' bin/plugin-files.txt) - -status "Restoring non-public icons... 🔁" -git diff --name-only --diff-filter=D -- packages/icons/src | sed 's|^| Restoring |' -git restore packages/icons/src - -success "Done. You've built Gutenberg! 🎉 " diff --git a/bin/plugin-files.txt b/bin/plugin-files.txt deleted file mode 100644 index 1f70a3db127fe9..00000000000000 --- a/bin/plugin-files.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Files and directories that make up the Gutenberg plugin. Used by -# bin/build-plugin-zip.sh and the performance tests workflow. -gutenberg.php -lib -packages/block-serialization-default-parser/*.php -packages/icons/src/manifest.php -packages/icons/src/library/*.svg -build -build-module -readme.txt -changelog.txt -README.md diff --git a/docs/contributors/code/getting-started-with-code-contribution.md b/docs/contributors/code/getting-started-with-code-contribution.md index f0b00615e1aeb2..e9e2850e7065f8 100644 --- a/docs/contributors/code/getting-started-with-code-contribution.md +++ b/docs/contributors/code/getting-started-with-code-contribution.md @@ -198,7 +198,7 @@ To build: open a terminal (or if on Windows, a command prompt) and navigate to t After building the cloned gutenberg directory contains the complete plugin, you can upload the entire repository to your `wp-content/plugins` directory and activate the plugin from the WordPress admin. -Another way to upload after building is to run `npm run build:plugin-zip` to create a plugin zip file — this requires `bash` and `php` to run. The script creates `gutenberg.zip` that you can use to install Gutenberg through the WordPress admin. +Another way to upload after building is to run `npm run build:plugin-zip`, which creates a `gutenberg.zip` that you can use to install Gutenberg through the WordPress admin. ## Storybook diff --git a/package-lock.json b/package-lock.json index 9c72a00b29891a..5ec84831a419a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48596,6 +48596,7 @@ "dependencies": { "@inquirer/prompts": "^7.2.0", "@octokit/rest": "^22.0.1", + "adm-zip": "^0.6.1", "chalk": "^4.1.1", "commander": "^9.2.0", "execa": "^4.0.2", @@ -48610,6 +48611,7 @@ "release-cli": "cli.js" }, "devDependencies": { + "@types/node": "^24.13.3", "vitest": "^5.0.0" } }, diff --git a/package.json b/package.json index 1384dfb82d0135..78b97ca8efce57 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "scripts": { "agents:setup": "npm run --workspace @wordpress/agent-tools setup --", "build": "npm run --workspace @wordpress/build-scripts build:all --", - "build:plugin-zip": "bash ./bin/build-plugin-zip.sh", + "build:plugin-zip": "npm run --workspace @wordpress/release-tools build-plugin-zip --", "check:type-declarations": "npm run --workspace @wordpress/build-scripts check-type-declarations --", "clean:package-types": "npm run --workspace @wordpress/build-scripts clean:package-types --", "clean:packages": "npm run --workspace @wordpress/build-scripts clean:packages --", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 394ecf69a7a090..2810e5e4cbe128 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -45,7 +45,6 @@ - ./bin ./gutenberg.php ./lib ./packages diff --git a/tools/monorepo/package.json b/tools/monorepo/package.json index 7ff57aee0cb734..cfc62251102fc7 100644 --- a/tools/monorepo/package.json +++ b/tools/monorepo/package.json @@ -21,6 +21,7 @@ "bugs": { "url": "https://github.com/WordPress/gutenberg/issues" }, + "type": "module", "exports": { "./tsconfig/*": "./tsconfig/tsconfig.*" }, diff --git a/tools/release/build-plugin-zip.mts b/tools/release/build-plugin-zip.mts new file mode 100644 index 00000000000000..fb7b4d13ebbf8f --- /dev/null +++ b/tools/release/build-plugin-zip.mts @@ -0,0 +1,333 @@ +/** + * Builds `gutenberg.zip`, the archive published to the WordPress.org plugin + * repository and handed to `wordpress-develop`. + * + * Set `NO_CHECKS` to skip the dirty tree guard and the repository reset, which + * is what CI does on a checkout that is already pristine. Set + * `IS_WORDPRESS_CORE` to `true` to leave out the icons Core does not ship. + */ + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline/promises'; +import { fileURLToPath } from 'node:url'; +import { styleText } from 'node:util'; +import { PLUGIN_FILES } from './plugin-files.mts'; + +/* + * Nothing outside Node is imported at load time: this script installs the + * dependencies it archives with, so on a fresh checkout `node_modules` does not + * exist yet. `createArchive` imports its two packages once `npm ci` has run. + */ + +const REPO_ROOT = path.resolve( + path.dirname( fileURLToPath( import.meta.url ) ), + '../..' +); +const ICONS_DIR = path.join( REPO_ROOT, 'packages', 'icons', 'src' ); +const ARCHIVE = path.join( REPO_ROOT, 'gutenberg.zip' ); +const IS_WINDOWS = process.platform === 'win32'; + +type Colour = 'red' | 'blue' | 'green' | 'yellow'; +const announce = ( colour: Colour, message: string ) => + console.log( `\n${ styleText( [ 'bold', colour ], message ) }\n` ); +const error = ( message: string ) => announce( 'red', message ); +const status = ( message: string ) => announce( 'blue', message ); +const success = ( message: string ) => announce( 'green', message ); +const warning = ( message: string ) => announce( 'yellow', message ); + +/** + * Describes a thrown value. A rejection is not always an `Error`: `adm-zip` + * rejects with a string. + * + * @param value The thrown value. + * @return Something worth printing. + */ +const reason = ( value: unknown ) => + value instanceof Error ? value.message : String( value ); + +/** + * Runs a command, letting it write straight to this process' streams. + * + * @param command Binary to run. + * @param args Arguments to pass to it. + */ +function run( command: string, args: string[] ): void { + if ( IS_WINDOWS ) { + /* + * Windows ships npm as `npm.cmd`, which Node will not launch without a + * shell. Handing `shell` an argument array as well is deprecated + * (DEP0190), and every argument here is a fixed literal, so the command + * line is assembled instead. + */ + execFileSync( [ command, ...args ].join( ' ' ), [], { + cwd: REPO_ROOT, + stdio: 'inherit', + shell: true, + } ); + return; + } + + execFileSync( command, args, { cwd: REPO_ROOT, stdio: 'inherit' } ); +} + +/** + * Runs git and captures what it printed. + * + * @param args Arguments to pass to git. + * @return The trimmed standard output. + */ +function git( ...args: string[] ): string { + return execFileSync( 'git', args, { + cwd: REPO_ROOT, + encoding: 'utf8', + maxBuffer: Infinity, + } ).trim(); +} + +/** + * Refuses to build from a working tree holding changes. Release builds must be + * traceable to a particular commit and reliably reproducible. + */ +function assertPristineTree(): void { + let changed; + try { + git( 'diff', '--exit-code' ); + git( 'diff', '--cached', '--exit-code' ); + } catch { + changed = true; + } + + if ( changed ) { + run( 'git', [ 'status' ] ); + error( + 'ERROR: Cannot build plugin zip with dirty working tree. ☝️\nCommit your changes and try again.' + ); + process.exit( 1 ); + } +} + +/** + * Removes ignored files so the build starts from a pristine repository. The + * removals are listed and confirmed first, so nobody loses a file they wanted. + */ +async function resetRepository(): Promise< void > { + status( 'Resetting the repository to pristine condition. ✨' ); + + const toClean = git( 'clean', '-xdf', '--dry-run' ); + if ( ! toClean ) { + return; + } + + console.log( toClean ); + warning( '🚨 About to delete everything above! Is this okay? 🚨' ); + + const rl = readline.createInterface( { + input: process.stdin, + output: process.stdout, + } ); + const answer = await rl.question( '[y]es/[N]o: ' ); + rl.close(); + + if ( ! answer.toLowerCase().startsWith( 'y' ) ) { + error( 'Fair enough; aborting. Tidy up your repo and try again. 🙂' ); + process.exit( 1 ); + } + + status( 'Cleaning working directory... 🛀' ); + run( 'git', [ 'clean', '-xdf' ] ); +} + +/** + * Deletes the icons WordPress Core does not ship. An icon ships when its + * manifest entry carries a `public` property, whether that property is `true` + * or `false`. + * + * This runs after the build so that icon collection validation still sees the + * full library. + */ +function prunePrivateIcons(): void { + status( 'Pruning non-public icons for WordPress Core... ✂️' ); + + const manifest: { filePath: string; public?: boolean }[] = JSON.parse( + fs.readFileSync( path.join( ICONS_DIR, 'manifest.json' ), 'utf8' ) + ); + const shipped = new Set( + manifest + .filter( + ( icon ) => icon.public !== undefined && icon.public !== null + ) + .map( ( icon ) => icon.filePath ) + ); + + const library = fs + .readdirSync( path.join( ICONS_DIR, 'library' ) ) + .filter( ( file ) => file.endsWith( '.svg' ) ) + .sort(); + + for ( const file of library ) { + const filePath = `library/${ file }`; + if ( shipped.has( filePath ) ) { + continue; + } + console.log( ` Deleting packages/icons/src/${ filePath }` ); + fs.rmSync( path.join( ICONS_DIR, filePath ) ); + } +} + +/** + * Restores the icons `prunePrivateIcons` deleted, listing them as it goes. + */ +function restorePrivateIcons(): void { + status( 'Restoring non-public icons... 🔁' ); + + const deleted = git( + 'diff', + '--name-only', + '--diff-filter=D', + '--', + 'packages/icons/src' + ); + if ( deleted ) { + console.log( + deleted + .split( '\n' ) + .map( ( file ) => ` Restoring ${ file }` ) + .join( '\n' ) + ); + } + + run( 'git', [ 'restore', 'packages/icons/src' ] ); +} + +/** + * Expands a plugin file entry into a fast-glob pattern. A directory stands for + * everything below it including dot files, which is what `zip --recurse-paths` + * gave the shell script. An explicit glob keeps Bash's rule that `*` does not + * match a leading dot. + * + * @param entry Repository-relative path or glob. + * @return The pattern to match with, and whether it takes dot files. + */ +function toPattern( entry: string ): { pattern: string; dot: boolean } { + const absolute = path.join( REPO_ROOT, entry ); + const isDirectory = + fs.existsSync( absolute ) && fs.statSync( absolute ).isDirectory(); + return isDirectory + ? { pattern: `${ entry }/**`, dot: true } + : { pattern: entry, dot: false }; +} + +/** + * Collects the plugin files and writes them to `gutenberg.zip`. Entry paths are + * repository relative and the archive holds no directory records, matching the + * `zip --recurse-paths --no-dir-entries` invocation this replaces. + */ +async function createArchive(): Promise< void > { + status( 'Creating archive... 🎁' ); + + const { default: AdmZip } = await import( 'adm-zip' ); + const { default: glob } = await import( 'fast-glob' ); + + const seen = new Set< string >(); + const zip = new AdmZip(); + + for ( const entry of PLUGIN_FILES ) { + const { pattern, dot } = toPattern( entry ); + const files = await glob( pattern, { + cwd: REPO_ROOT, + onlyFiles: true, + dot, + } ); + + if ( ! files.length ) { + warning( + `Nothing matched ${ entry }; it is missing from the zip.` + ); + continue; + } + + for ( const file of files.sort() ) { + if ( seen.has( file ) ) { + continue; + } + seen.add( file ); + + const directory = path.dirname( file ); + zip.addLocalFile( + path.join( REPO_ROOT, file ), + directory === '.' ? '' : directory, + path.basename( file ) + ); + } + } + + /* + * `zip` built the archive in a temporary sibling and renamed it on success, + * leaving the previous one intact when a run failed. adm-zip truncates the + * target first, and its writer reports a short write as a success, so the + * buffer goes to disk through Node. + */ + const temporary = `${ ARCHIVE }.${ process.pid }.tmp`; + try { + fs.writeFileSync( temporary, await zip.toBufferPromise() ); + fs.renameSync( temporary, ARCHIVE ); + } finally { + // Cleaning up must not throw over whatever sent us here. + fs.rmSync( temporary, { force: true, recursive: true } ); + } +} + +async function main(): Promise< void > { + status( '💃 Time to build the Gutenberg plugin ZIP file 🕺' ); + + /* + * This script runs in Node, so it reads the real environment. The rules + * below point at the globals esbuild defines for bundled sources instead. + */ + // eslint-disable-next-line @wordpress/no-wp-process-env, @wordpress/wp-global-usage + const isWordPressCore = process.env.IS_WORDPRESS_CORE === 'true'; + + if ( ! process.env.NO_CHECKS ) { + assertPristineTree(); + await resetRepository(); + } + + status( 'Installing dependencies... 📦' ); + run( 'npm', [ 'cache', 'verify' ] ); + run( 'npm', [ 'ci' ] ); + + status( 'Generating build... 👷‍♀️' ); + run( 'npm', [ 'run', 'build' ] ); + + // Wrapped, because a thrown value is not necessarily truthy. + let failure: { error: unknown } | undefined; + try { + if ( isWordPressCore ) { + prunePrivateIcons(); + } + await createArchive(); + } catch ( archiveError ) { + /* + * Reported here rather than rethrown, so that the icons are restored + * first and a restore that fails in turn cannot bury the cause. + */ + failure = { error: archiveError }; + error( + `ERROR: Could not create the archive. ${ reason( archiveError ) }` + ); + } + + if ( isWordPressCore ) { + restorePrivateIcons(); + } + + if ( failure ) { + throw failure.error; + } + + success( "Done. You've built Gutenberg! 🎉 " ); +} + +await main(); diff --git a/tools/release/config.js b/tools/release/config.js index aa252aa9894668..3b0e25332bd156 100644 --- a/tools/release/config.js +++ b/tools/release/config.js @@ -13,7 +13,6 @@ const gitRepoOwner = 'WordPress'; * @property {string} githubRepositoryOwner GitHub Repository Owner. * @property {string} githubRepositoryName GitHub Repository Name. * @property {string} pluginEntryPoint Plugin Entry Point File. - * @property {string} buildZipCommand Build Plugin ZIP command. * @property {string} githubRepositoryURL GitHub Repository URL. * @property {string} wpRepositoryReleasesURL WordPress Repository Tags URL. * @property {string} gitRepositoryURL Git Repository URL. @@ -31,7 +30,6 @@ const config = { githubRepositoryOwner: gitRepoOwner, githubRepositoryName: 'gutenberg', pluginEntryPoint: 'gutenberg.php', - buildZipCommand: '/bin/bash bin/build-plugin-zip.sh', githubRepositoryURL: 'https://github.com/' + gitRepoOwner + '/gutenberg/', wpRepositoryReleasesURL: 'https://github.com/WordPress/gutenberg/releases/', gitRepositoryURL: 'https://github.com/' + gitRepoOwner + '/gutenberg.git', diff --git a/tools/release/package.json b/tools/release/package.json index 60e283fae2aaed..2197fb1831194e 100644 --- a/tools/release/package.json +++ b/tools/release/package.json @@ -25,6 +25,7 @@ "dependencies": { "@inquirer/prompts": "^7.2.0", "@octokit/rest": "^22.0.1", + "adm-zip": "^0.6.1", "chalk": "^4.1.1", "commander": "^9.2.0", "execa": "^4.0.2", @@ -36,12 +37,14 @@ "sprintf-js": "^1.1.1" }, "devDependencies": { + "@types/node": "^24.13.3", "vitest": "^5.0.0" }, "publishConfig": { "access": "public" }, "scripts": { + "build-plugin-zip": "node ./build-plugin-zip.mts", "cherry-pick": "node ./cherry-pick.mjs", "generate-php-sync-issue": "node ./generate-php-sync-issue.mjs", "log-performance-results": "node ./log-performance-results.js" diff --git a/tools/release/plugin-files.mts b/tools/release/plugin-files.mts new file mode 100644 index 00000000000000..a90da06a7a3aa5 --- /dev/null +++ b/tools/release/plugin-files.mts @@ -0,0 +1,19 @@ +/** + * The files and directories that make up the Gutenberg plugin. Read by + * `build-plugin-zip.mts` when creating the archive and by + * `resolve-performance-branches.mjs` when packaging a tested commit. + * + * Directories are listed bare and stand for everything below them. + */ +export const PLUGIN_FILES = [ + 'gutenberg.php', + 'lib', + 'packages/block-serialization-default-parser/*.php', + 'packages/icons/src/manifest.php', + 'packages/icons/src/library/*.svg', + 'build', + 'build-module', + 'readme.txt', + 'changelog.txt', + 'README.md', +]; diff --git a/tools/release/resolve-performance-branches.mjs b/tools/release/resolve-performance-branches.mjs index 3235796f06659d..dc61deacc3bf66 100644 --- a/tools/release/resolve-performance-branches.mjs +++ b/tools/release/resolve-performance-branches.mjs @@ -2,7 +2,7 @@ /** * Resolves the branches and the suite shards the performance workflow runs for a GitHub event. * Writes `branches` (`{ name, ref, artifact, sha?, reusable? }[]`), `shards` (`{ shard, suites }[]`), - * `wp-version`, `plugin-files` (space separated globs from bin/plugin-files.txt) and `build-key` + * `wp-version`, `plugin-files` (space separated globs from plugin-files.mts) and `build-key` * to `$GITHUB_OUTPUT`. */ import fs from 'node:fs'; @@ -11,6 +11,7 @@ import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { pathToFileURL } from 'node:url'; import { sanitizeBranchName } from './lib/sanitize-branch-name.js'; +import { PLUGIN_FILES } from './plugin-files.mts'; /* * The commit trunk is compared against; must be updated on every WP major release, see @@ -274,12 +275,7 @@ function main() { .map( ( file ) => path.basename( file, '.spec.js' ) ) ); - const pluginFiles = fs - .readFileSync( path.join( 'bin', 'plugin-files.txt' ), 'utf8' ) - .split( '\n' ) - .map( ( line ) => line.trim() ) - .filter( ( line ) => line && ! line.startsWith( '#' ) ) - .join( ' ' ); + const pluginFiles = PLUGIN_FILES.join( ' ' ); const buildKey = computeBuildKey( pluginFiles, diff --git a/tools/release/tsconfig.json b/tools/release/tsconfig.json new file mode 100644 index 00000000000000..162145cc4274b1 --- /dev/null +++ b/tools/release/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "@wordpress/monorepo-tools/tsconfig/node.base.json", + "include": [ "*.mts" ] +} diff --git a/tsconfig.json b/tsconfig.json index c69d657a50ed6c..7f92a77d1700be 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -93,6 +93,7 @@ { "path": "storybook" }, { "path": "tools/monorepo" }, { "path": "tools/pr-meta" }, + { "path": "tools/release" }, { "path": "tools/validation" }, { "path": "test/e2e" }, { "path": "test/performance" }, From 816bcc479cf6cdc099583c3b5ada8b6053a31a31 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 21 Sep 2026 18:25:20 +0530 Subject: [PATCH 2/2] Release tools: Check for a dirty tree with git diff --quiet `--quiet` implies `--exit-code` and only suppresses the diff, which the shell script discarded with `> /dev/null`. Capturing it instead buffered the whole diff of a dirty tree just to throw it away, and `--quiet` also lets git stop at the first difference. --- tools/release/build-plugin-zip.mts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/release/build-plugin-zip.mts b/tools/release/build-plugin-zip.mts index fb7b4d13ebbf8f..303322fb2bf360 100644 --- a/tools/release/build-plugin-zip.mts +++ b/tools/release/build-plugin-zip.mts @@ -93,8 +93,8 @@ function git( ...args: string[] ): string { function assertPristineTree(): void { let changed; try { - git( 'diff', '--exit-code' ); - git( 'diff', '--cached', '--exit-code' ); + git( 'diff', '--quiet' ); + git( 'diff', '--cached', '--quiet' ); } catch { changed = true; }