From 8c78878a84e8c2bc6827b3c613171c6c2ef2a4c6 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:11:03 +0100 Subject: [PATCH 01/49] feat(action): add @node-minify/action GitHub Action package - Add new packages/action with Node20 runtime for GitHub Marketplace - Includes minification, PR comments, job summaries, annotations, benchmarking - Supports 22 compressors with deprecation warnings for legacy ones - Add getFilesizeGzippedRaw to utils for raw byte gzip sizes - Add minify-html to compressor registry - Add oxc to default benchmark compressors - Update .github/actions/node-minify to use resolveCompressor from utils - Refactor action imports to use named imports instead of namespace imports --- .github/actions/node-minify/minify.ts | 63 +-------- AGENTS.md | 2 +- bun.lock | 72 +++++++++- docs/src/content/docs/benchmark.md | 6 +- docs/src/content/docs/cli.md | 4 +- packages/action/README.md | 124 ++++++++++++++++++ packages/action/__tests__/action.test.ts | 96 ++++++++++++++ packages/action/action.yml | 87 ++++++++++++ packages/action/package.json | 62 +++++++++ packages/action/src/index.ts | 69 ++++++++++ packages/action/src/inputs.ts | 83 ++++++++++++ packages/action/src/minify.ts | 70 ++++++++++ packages/action/src/outputs.ts | 37 ++++++ packages/action/src/reporters/annotations.ts | 41 ++++++ packages/action/src/reporters/comment.ts | 89 +++++++++++++ packages/action/src/reporters/summary.ts | 93 +++++++++++++ packages/action/src/types.ts | 69 ++++++++++ packages/action/tsconfig.json | 8 ++ packages/action/vitest.config.ts | 7 + packages/benchmark/README.md | 6 +- packages/benchmark/src/runner.ts | 7 +- packages/utils/src/compressor-resolver.ts | 1 + .../utils/src/getFilesizeGzippedInBytes.ts | 87 ++++++++---- packages/utils/src/index.ts | 6 +- 24 files changed, 1089 insertions(+), 100 deletions(-) create mode 100644 packages/action/README.md create mode 100644 packages/action/__tests__/action.test.ts create mode 100644 packages/action/action.yml create mode 100644 packages/action/package.json create mode 100644 packages/action/src/index.ts create mode 100644 packages/action/src/inputs.ts create mode 100644 packages/action/src/minify.ts create mode 100644 packages/action/src/outputs.ts create mode 100644 packages/action/src/reporters/annotations.ts create mode 100644 packages/action/src/reporters/comment.ts create mode 100644 packages/action/src/reporters/summary.ts create mode 100644 packages/action/src/types.ts create mode 100644 packages/action/tsconfig.json create mode 100644 packages/action/vitest.config.ts diff --git a/.github/actions/node-minify/minify.ts b/.github/actions/node-minify/minify.ts index d8f579fe5..0987f9f74 100644 --- a/.github/actions/node-minify/minify.ts +++ b/.github/actions/node-minify/minify.ts @@ -2,65 +2,10 @@ import { appendFileSync, existsSync } from "node:fs"; import { stat } from "node:fs/promises"; import { resolve } from "node:path"; import { minify } from "@node-minify/core"; -import { getFilesizeGzippedInBytes } from "@node-minify/utils"; - -const KNOWN_COMPRESSOR_EXPORTS: Record = { - esbuild: "esbuild", - "google-closure-compiler": "gcc", - gcc: "gcc", - oxc: "oxc", - swc: "swc", - terser: "terser", - "uglify-js": "uglifyJs", - "babel-minify": "babelMinify", - "uglify-es": "uglifyEs", - yui: "yui", - "clean-css": "cleanCss", - cssnano: "cssnano", - csso: "csso", - lightningcss: "lightningCss", - crass: "crass", - sqwish: "sqwish", - "html-minifier": "htmlMinifier", - jsonminify: "jsonMinify", - imagemin: "imagemin", - sharp: "sharp", - svgo: "svgo", - "no-compress": "noCompress", -}; - -/** - * Resolves a compressor function from the @node-minify/{name} package and returns it with a label. - * - * @param name - Compressor package identifier (e.g., "terser", "esbuild"); used to import @node-minify/{name} - * @returns An object containing `compressor` (the resolved compressor function) and `label` (the provided name) - * @throws Error if the package does not export a usable compressor function - */ -async function resolveCompressor( - name: string -): Promise<{ compressor: unknown; label: string }> { - const packageName = `@node-minify/${name}`; - const mod = (await import(packageName)) as Record; - - const knownExport = KNOWN_COMPRESSOR_EXPORTS[name]; - if (knownExport && typeof mod[knownExport] === "function") { - return { compressor: mod[knownExport], label: name }; - } - - if (typeof mod.default === "function") { - return { compressor: mod.default, label: name }; - } - - for (const value of Object.values(mod)) { - if (typeof value === "function") { - return { compressor: value, label: name }; - } - } - - throw new Error( - `Package '${packageName}' doesn't export a valid compressor function.` - ); -} +import { + getFilesizeGzippedInBytes, + resolveCompressor, +} from "@node-minify/utils"; interface ActionResult { originalSize: number; diff --git a/AGENTS.md b/AGENTS.md index 2466890bd..22539c172 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,7 +273,7 @@ node-minify --compressor terser --input src/app.js --output dist/app.min.js node-minify -c esbuild -i "src/**/*.js" -o dist/bundle.js -t js -O '{"minify":true}' # Benchmark -node-minify benchmark src/app.js --compressors terser,esbuild,swc --format json +node-minify benchmark src/app.js --compressors terser,esbuild,swc,oxc --format json ``` **Benchmark formats**: `console` (default, colored tables), `json`, `markdown` diff --git a/bun.lock b/bun.lock index 5cabb7356..943fd8606 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,20 @@ "@node-minify/yui": "workspace:*", }, }, + "packages/action": { + "name": "@node-minify/action", + "version": "10.3.0", + "dependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.0", + "@node-minify/core": "workspace:*", + "@node-minify/utils": "workspace:*", + }, + "devDependencies": { + "@node-minify/types": "workspace:*", + "@vercel/ncc": "^0.38.3", + }, + }, "packages/babel-minify": { "name": "@node-minify/babel-minify", "version": "10.3.0", @@ -413,6 +427,16 @@ }, }, "packages": { + "@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="], + + "@actions/exec": ["@actions/exec@1.1.1", "", { "dependencies": { "@actions/io": "^1.0.1" } }, "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w=="], + + "@actions/github": ["@actions/github@6.0.1", "", { "dependencies": { "@actions/http-client": "^2.2.0", "@octokit/core": "^5.0.1", "@octokit/plugin-paginate-rest": "^9.2.2", "@octokit/plugin-rest-endpoint-methods": "^10.4.0", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "undici": "^5.28.5" } }, "sha512-xbZVcaqD4XnQAe35qSQqskb3SqIAfRyLBrHMd/8TuL7hJSz2QtbDwnNM8zWx4zO5l2fnGtseNE3MbEvD7BxVMw=="], + + "@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], + + "@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="], + "@andrewbranch/untar.js": ["@andrewbranch/untar.js@1.0.3", "", {}, "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw=="], "@arethetypeswrong/cli": ["@arethetypeswrong/cli@0.18.2", "", { "dependencies": { "@arethetypeswrong/core": "0.18.2", "chalk": "^4.1.2", "cli-table3": "^0.6.3", "commander": "^10.0.1", "marked": "^9.1.2", "marked-terminal": "^7.1.0", "semver": "^7.5.4" }, "bin": { "attw": "dist/index.js" } }, "sha512-PcFM20JNlevEDKBg4Re29Rtv2xvjvQZzg7ENnrWFSS0PHgdP2njibVFw+dRUhNkPgNfac9iUqO0ohAXqQL4hbw=="], @@ -625,6 +649,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -713,6 +739,8 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@node-minify/action": ["@node-minify/action@workspace:packages/action"], + "@node-minify/babel-minify": ["@node-minify/babel-minify@workspace:packages/babel-minify"], "@node-minify/benchmark": ["@node-minify/benchmark@workspace:packages/benchmark"], @@ -779,6 +807,26 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@octokit/auth-token": ["@octokit/auth-token@4.0.0", "", {}, "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA=="], + + "@octokit/core": ["@octokit/core@5.2.2", "", { "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.0.0", "before-after-hook": "^2.2.0", "universal-user-agent": "^6.0.0" } }, "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg=="], + + "@octokit/endpoint": ["@octokit/endpoint@9.0.6", "", { "dependencies": { "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw=="], + + "@octokit/graphql": ["@octokit/graphql@7.1.1", "", { "dependencies": { "@octokit/request": "^8.4.1", "@octokit/types": "^13.0.0", "universal-user-agent": "^6.0.0" } }, "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@9.2.2", "", { "dependencies": { "@octokit/types": "^12.6.0" }, "peerDependencies": { "@octokit/core": "5" } }, "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@10.4.1", "", { "dependencies": { "@octokit/types": "^12.6.0" }, "peerDependencies": { "@octokit/core": "5" } }, "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg=="], + + "@octokit/request": ["@octokit/request@8.4.1", "", { "dependencies": { "@octokit/endpoint": "^9.0.6", "@octokit/request-error": "^5.1.1", "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw=="], + + "@octokit/request-error": ["@octokit/request-error@5.1.1", "", { "dependencies": { "@octokit/types": "^13.1.0", "deprecation": "^2.0.0", "once": "^1.4.0" } }, "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g=="], + + "@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], "@oxc-minify/binding-android-arm-eabi": ["@oxc-minify/binding-android-arm-eabi@0.108.0", "", { "os": "android", "cpu": "arm" }, "sha512-obfkLrlAv40lAE6C9eYameBKLpTJ/ToynpBbTwb+wSVg+HXYzLoFYy1M5V9/otjCnxxVpPdnHsOqw8aGCRT0WA=="], @@ -1045,6 +1093,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@vercel/ncc": ["@vercel/ncc@0.38.4", "", { "bin": { "ncc": "dist/ncc/cli.js" } }, "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.16", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.16", "ast-v8-to-istanbul": "^0.3.8", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.16", "vitest": "4.0.16" }, "optionalPeers": ["@vitest/browser"] }, "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A=="], "@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="], @@ -1293,6 +1343,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], + "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], + "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], "bin-build": ["bin-build@3.0.0", "", { "dependencies": { "decompress": "^4.0.0", "download": "^6.2.2", "execa": "^0.7.0", "p-map-series": "^1.0.0", "tempfile": "^2.0.0" } }, "sha512-jcUOof71/TNAI2uM5uoUaDq2ePcVBQ3R/qhxAz1rX7UfvduAL/RXD3jXzvn8cVcDJdGVkiR1shal3OH0ImpuhA=="], @@ -1499,6 +1551,8 @@ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "deprecation": ["deprecation@2.3.1", "", {}, "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -2583,6 +2637,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], @@ -2607,7 +2663,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -2643,6 +2699,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universal-user-agent": ["universal-user-agent@6.0.1", "", {}, "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ=="], + "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "unrun": ["unrun@0.2.21", "", { "dependencies": { "rolldown": "1.0.0-beta.57" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-VuwI4YKtwBpDvM7hCEop2Im/ezS82dliqJpkh9pvS6ve8HcUsBDvESHxMmUfImXR03GkmfdDynyrh/pUJnlguw=="], @@ -2795,6 +2853,10 @@ "@manypkg/get-packages/globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], + "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], "@poppinss/colors/kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], @@ -2987,6 +3049,8 @@ "miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + "miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + "miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], "node-emoji/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], @@ -3099,6 +3163,10 @@ "@manypkg/get-packages/globby/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], + "@preact/preset-vite/@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@preact/preset-vite/@rollup/pluginutils/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -3511,6 +3579,8 @@ "@astrojs/cloudflare/wrangler/miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], + "@astrojs/cloudflare/wrangler/miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], + "@astrojs/cloudflare/wrangler/miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], "@astrojs/cloudflare/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251118.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-UmWmYEYS/LkK/4HFKN6xf3Hk8cw70PviR+ftr3hUvs9HYZS92IseZEp16pkL6ZBETrPRpZC7OrzoYF7ky6kHsg=="], diff --git a/docs/src/content/docs/benchmark.md b/docs/src/content/docs/benchmark.md index dda1c8202..3d35b59c4 100644 --- a/docs/src/content/docs/benchmark.md +++ b/docs/src/content/docs/benchmark.md @@ -28,7 +28,7 @@ node-minify benchmark src/app.js ### Compare Specific Compressors ```bash -node-minify benchmark src/app.js --compressors terser,esbuild,swc +node-minify benchmark src/app.js --compressors terser,esbuild,swc,oxc ``` ### Custom Compressors @@ -84,7 +84,7 @@ import { benchmark } from '@node-minify/benchmark'; const results = await benchmark({ input: 'src/app.js', - compressors: ['terser', 'esbuild', 'swc'] + compressors: ['terser', 'esbuild', 'swc', 'oxc'] }); console.log(results.summary.recommended); @@ -124,7 +124,7 @@ for (const file of results.files) { | Option | Type | Description | Default | |--------|------|-------------|---------| | `input` | `string \| string[]` | File(s) or glob pattern to benchmark | Required | -| `compressors` | `string[]` | List of compressor names | `['terser', 'esbuild', 'swc']` | +| `compressors` | `string[]` | List of compressor names | `['terser', 'esbuild', 'swc', 'oxc']` | | `iterations` | `number` | Number of iterations per compressor | `1` | | `warmup` | `number` | Warmup runs before timing | `1` if iterations > 1 | | `includeGzip` | `boolean` | Include gzip size in results | `false` | diff --git a/docs/src/content/docs/cli.md b/docs/src/content/docs/cli.md index fe55624e4..34de0b892 100644 --- a/docs/src/content/docs/cli.md +++ b/docs/src/content/docs/cli.md @@ -78,7 +78,7 @@ node-minify benchmark src/app.js ### Compare Specific Compressors ```bash -node-minify benchmark src/app.js --compressors terser,esbuild,swc +node-minify benchmark src/app.js --compressors terser,esbuild,swc,oxc ``` ### With Options @@ -105,7 +105,7 @@ node-minify benchmark src/app.js -c terser,esbuild -f markdown | Option | Description | Default | |--------|-------------|---------| -| `-c, --compressors` | Comma-separated list of compressors | `terser,esbuild,swc` | +| `-c, --compressors` | Comma-separated list of compressors | `terser,esbuild,swc,oxc` | | `-n, --iterations` | Number of iterations | `1` | | `-f, --format` | Output format: `console`, `json`, `markdown` | `console` | | `-o, --output` | Output file path | stdout | diff --git a/packages/action/README.md b/packages/action/README.md new file mode 100644 index 000000000..1721eeee5 --- /dev/null +++ b/packages/action/README.md @@ -0,0 +1,124 @@ +# @node-minify/action + +GitHub Action for minifying JavaScript, CSS, and HTML files with detailed reporting. + +## Features + +- 📦 **Minification** - Compress JS, CSS, HTML files using 15+ compressors +- 📊 **Job Summary** - Detailed compression statistics in workflow UI +- 💬 **PR Comments** - Automatic size reports on pull requests +- ⚠️ **Annotations** - File-level warnings for low compression +- 🎯 **Thresholds** - Fail builds on size regressions +- 🏁 **Benchmark** - Compare compressor performance + +## Usage + +### Basic Minification + +```yaml +- name: Minify JavaScript + uses: srod/node-minify/packages/action@main + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" +``` + +### With PR Comment + +```yaml +- name: Minify and Report + uses: srod/node-minify/packages/action@main + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "esbuild" + type: "js" + report-summary: true + report-pr-comment: true + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +### With Thresholds + +```yaml +- name: Minify with Quality Gates + uses: srod/node-minify/packages/action@main + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" + fail-on-increase: true + min-reduction: 50 +``` + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `input` | Files to minify (glob pattern or path) | Yes | - | +| `output` | Output file path | Yes | - | +| `compressor` | Compressor to use | No | `terser` | +| `type` | File type: js or css | No | - | +| `options` | Compressor options (JSON) | No | `{}` | +| `report-summary` | Add results to job summary | No | `true` | +| `report-pr-comment` | Post results as PR comment | No | `false` | +| `report-annotations` | Add file annotations | No | `false` | +| `fail-on-increase` | Fail if size increases | No | `false` | +| `min-reduction` | Minimum reduction % | No | `0` | +| `include-gzip` | Include gzip sizes | No | `true` | +| `github-token` | Token for PR comments | No | - | + +### Available Compressors + +**JavaScript:** +- `terser` (recommended) +- `esbuild` (fastest, requires `type: js`) +- `swc` +- `oxc` +- `uglify-js` +- `google-closure-compiler` / `gcc` (requires Java) + +**CSS:** +- `lightningcss` (recommended, requires `type: css`) +- `clean-css` +- `cssnano` +- `csso` +- `esbuild` (requires `type: css`) + +**HTML:** +- `html-minifier` +- `minify-html` + +**JSON:** +- `jsonminify` + +**Image:** +- `sharp` (WebP/AVIF conversion) +- `svgo` (SVG optimization) +- `imagemin` (PNG/JPEG/GIF) + +**Other:** +- `no-compress` (passthrough, concatenation only) + +**Deprecated (emit warnings):** +- `babel-minify` → use `terser` +- `uglify-es` → use `terser` +- `yui` → use `terser` (JS) or `lightningcss` (CSS) +- `crass` → use `lightningcss` or `clean-css` +- `sqwish` → use `lightningcss` or `clean-css` + +## Outputs + +| Output | Description | +|--------|-------------| +| `original-size` | Original size in bytes | +| `minified-size` | Minified size in bytes | +| `reduction-percent` | Size reduction percentage | +| `gzip-size` | Gzipped size in bytes | +| `time-ms` | Compression time in ms | +| `report-json` | Full report as JSON | + +## License + +MIT diff --git a/packages/action/__tests__/action.test.ts b/packages/action/__tests__/action.test.ts new file mode 100644 index 000000000..20c3c9c93 --- /dev/null +++ b/packages/action/__tests__/action.test.ts @@ -0,0 +1,96 @@ +/*! node-minify action tests - MIT Licensed */ + +import { describe, expect, test } from "vitest"; +import type { ActionInputs, FileResult, MinifyResult } from "../src/types.ts"; + +describe("Action Types", () => { + test("ActionInputs has required fields", () => { + const inputs: ActionInputs = { + input: "src/app.js", + output: "dist/app.min.js", + compressor: "terser", + options: {}, + reportSummary: true, + reportPRComment: false, + reportAnnotations: false, + benchmark: false, + benchmarkCompressors: ["terser", "esbuild"], + failOnIncrease: false, + minReduction: 0, + includeGzip: true, + workingDirectory: ".", + }; + + expect(inputs.input).toBe("src/app.js"); + expect(inputs.compressor).toBe("terser"); + }); + + test("FileResult has all metrics", () => { + const result: FileResult = { + file: "app.js", + originalSize: 10000, + minifiedSize: 3000, + reduction: 70, + gzipSize: 1000, + timeMs: 50, + }; + + expect(result.reduction).toBe(70); + expect(result.gzipSize).toBe(1000); + }); + + test("MinifyResult aggregates file results", () => { + const result: MinifyResult = { + files: [ + { + file: "a.js", + originalSize: 5000, + minifiedSize: 1500, + reduction: 70, + timeMs: 25, + }, + { + file: "b.js", + originalSize: 5000, + minifiedSize: 1500, + reduction: 70, + timeMs: 25, + }, + ], + compressor: "terser", + totalOriginalSize: 10000, + totalMinifiedSize: 3000, + totalReduction: 70, + totalTimeMs: 50, + }; + + expect(result.files).toHaveLength(2); + expect(result.totalReduction).toBe(70); + }); +}); + +describe("Threshold Logic", () => { + test("should detect size increase", () => { + const reduction = -5; + const failOnIncrease = true; + + const shouldFail = failOnIncrease && reduction < 0; + expect(shouldFail).toBe(true); + }); + + test("should detect insufficient reduction", () => { + const reduction = 30; + const minReduction = 50; + + const shouldFail = minReduction > 0 && reduction < minReduction; + expect(shouldFail).toBe(true); + }); + + test("should pass when reduction meets threshold", () => { + const reduction = 60; + const minReduction = 50; + + const shouldFail = minReduction > 0 && reduction < minReduction; + expect(shouldFail).toBe(false); + }); +}); diff --git a/packages/action/action.yml b/packages/action/action.yml new file mode 100644 index 000000000..54c12df00 --- /dev/null +++ b/packages/action/action.yml @@ -0,0 +1,87 @@ +name: "node-minify" +description: "Minify JavaScript, CSS, and HTML files with detailed reporting and PR comments" +author: "srod" +branding: + icon: "minimize-2" + color: "green" + +inputs: + input: + description: "Files to minify (glob pattern or path)" + required: true + output: + description: "Output file path" + required: true + compressor: + description: | + Compressor to use. + Recommended: terser, esbuild, swc, oxc (fast, no Java). + Note: 'gcc' and 'yui' require Java (pre-installed on GitHub runners). + required: false + default: "terser" + type: + description: "File type: js or css (required for esbuild, lightningcss, yui)" + required: false + options: + description: "Compressor-specific options (JSON string)" + required: false + default: "{}" + report-summary: + description: "Add results to job summary" + required: false + default: "true" + report-pr-comment: + description: "Post results as PR comment" + required: false + default: "false" + report-annotations: + description: "Add file annotations for warnings" + required: false + default: "false" + benchmark: + description: "Run benchmark comparison across compressors" + required: false + default: "false" + benchmark-compressors: + description: "Compressors to compare (comma-separated)" + required: false + default: "terser,esbuild,swc,oxc" + fail-on-increase: + description: "Fail if minified size is larger than original" + required: false + default: "false" + min-reduction: + description: "Minimum reduction % required (0-100)" + required: false + default: "0" + include-gzip: + description: "Include gzip sizes in report" + required: false + default: "true" + working-directory: + description: "Working directory for file operations" + required: false + default: "." + github-token: + description: "GitHub token for PR comments" + required: false + +outputs: + original-size: + description: "Original file size in bytes" + minified-size: + description: "Minified file size in bytes" + reduction-percent: + description: "Size reduction percentage" + gzip-size: + description: "Gzipped size in bytes" + time-ms: + description: "Compression time in milliseconds" + report-json: + description: "Full report as JSON string" + benchmark-winner: + description: "Best compressor from benchmark (if run)" + +runs: + using: "node20" + main: "dist/index.js" diff --git a/packages/action/package.json b/packages/action/package.json new file mode 100644 index 000000000..62052662a --- /dev/null +++ b/packages/action/package.json @@ -0,0 +1,62 @@ +{ + "name": "@node-minify/action", + "version": "10.3.0", + "description": "GitHub Action for node-minify - minify JS, CSS, HTML with detailed reporting", + "keywords": [ + "github-action", + "minify", + "minifier", + "compressor", + "ci-cd" + ], + "author": "Rodolphe Stoclin ", + "homepage": "https://github.com/srod/node-minify/tree/main/packages/action#readme", + "license": "MIT", + "private": true, + "type": "module", + "engines": { + "node": ">=20.0.0" + }, + "directories": { + "lib": "dist", + "test": "__tests__" + }, + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "exports": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "sideEffects": false, + "files": [ + "dist/**/*", + "action.yml" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/srod/node-minify.git" + }, + "bugs": { + "url": "https://github.com/srod/node-minify/issues" + }, + "scripts": { + "build": "tsdown src/index.ts", + "build:ncc": "ncc build src/index.ts -o dist --source-map --license licenses.txt", + "format:check": "biome check .", + "lint": "biome lint .", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@actions/core": "^1.11.1", + "@actions/github": "^6.0.0", + "@node-minify/core": "workspace:*", + "@node-minify/utils": "workspace:*" + }, + "devDependencies": { + "@node-minify/types": "workspace:*", + "@vercel/ncc": "^0.38.3" + } +} diff --git a/packages/action/src/index.ts b/packages/action/src/index.ts new file mode 100644 index 000000000..8dc4632bc --- /dev/null +++ b/packages/action/src/index.ts @@ -0,0 +1,69 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { info, setFailed } from "@actions/core"; +import { context } from "@actions/github"; +import { parseInputs, validateJavaCompressor } from "./inputs.ts"; +import { runMinification } from "./minify.ts"; +import { setMinifyOutputs } from "./outputs.ts"; +import { addAnnotations } from "./reporters/annotations.ts"; +import { postPRComment } from "./reporters/comment.ts"; +import { generateSummary } from "./reporters/summary.ts"; + +async function run(): Promise { + try { + const inputs = parseInputs(); + + validateJavaCompressor(inputs.compressor); + + info(`Minifying ${inputs.input} with ${inputs.compressor}...`); + + const result = await runMinification(inputs); + + setMinifyOutputs(result); + + if (inputs.reportSummary) { + await generateSummary(result); + } + + if (inputs.reportPRComment && context.payload.pull_request) { + await postPRComment(result, inputs.githubToken); + } + + if (inputs.reportAnnotations) { + addAnnotations(result); + } + + if (inputs.failOnIncrease && result.totalReduction < 0) { + setFailed( + `Minified size is larger than original (${result.totalReduction.toFixed(1)}% increase)` + ); + return; + } + + if ( + inputs.minReduction > 0 && + result.totalReduction < inputs.minReduction + ) { + setFailed( + `Reduction ${result.totalReduction.toFixed(1)}% is below minimum threshold ${inputs.minReduction}%` + ); + return; + } + + info( + `✅ Minification complete! ${result.totalReduction.toFixed(1)}% reduction in ${result.totalTimeMs}ms` + ); + } catch (error) { + if (error instanceof Error) { + setFailed(error.message); + } else { + setFailed("An unknown error occurred"); + } + } +} + +run(); diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts new file mode 100644 index 000000000..ace53de07 --- /dev/null +++ b/packages/action/src/inputs.ts @@ -0,0 +1,83 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { getBooleanInput, getInput, warning } from "@actions/core"; +import { isBuiltInCompressor } from "@node-minify/utils"; +import type { ActionInputs } from "./types.ts"; + +const TYPE_REQUIRED_COMPRESSORS = ["esbuild", "lightningcss", "yui"]; +const JAVA_COMPRESSORS = ["gcc", "google-closure-compiler", "yui"]; + +const DEPRECATED_COMPRESSORS: Record = { + "babel-minify": + "babel-minify only supports Babel 6 and is no longer maintained. Use 'terser' instead.", + "uglify-es": "uglify-es is no longer maintained. Use 'terser' instead.", + yui: "YUI Compressor was deprecated by Yahoo in 2013. Use 'terser' for JS or 'lightningcss' for CSS.", + crass: "crass is no longer maintained. Use 'lightningcss' or 'clean-css' instead.", + sqwish: "sqwish is no longer maintained. Use 'lightningcss' or 'clean-css' instead.", +}; + +export function parseInputs(): ActionInputs { + const compressor = getInput("compressor") || "terser"; + const type = getInput("type") as "js" | "css" | undefined; + + if (TYPE_REQUIRED_COMPRESSORS.includes(compressor) && !type) { + throw new Error( + `Compressor '${compressor}' requires the 'type' input (js or css)` + ); + } + + let options: Record = {}; + const optionsJson = getInput("options"); + if (optionsJson) { + try { + options = JSON.parse(optionsJson); + } catch { + throw new Error(`Invalid JSON in 'options' input: ${optionsJson}`); + } + } + + const benchmarkCompressorsInput = getInput("benchmark-compressors"); + const benchmarkCompressors = benchmarkCompressorsInput + ? benchmarkCompressorsInput.split(",").map((c: string) => c.trim()) + : ["terser", "esbuild", "swc", "oxc"]; + + return { + input: getInput("input", { required: true }), + output: getInput("output", { required: true }), + compressor, + type: type || undefined, + options, + reportSummary: getBooleanInput("report-summary"), + reportPRComment: getBooleanInput("report-pr-comment"), + reportAnnotations: getBooleanInput("report-annotations"), + benchmark: getBooleanInput("benchmark"), + benchmarkCompressors, + failOnIncrease: getBooleanInput("fail-on-increase"), + minReduction: Number.parseFloat(getInput("min-reduction")) || 0, + includeGzip: getBooleanInput("include-gzip"), + workingDirectory: getInput("working-directory") || ".", + githubToken: getInput("github-token") || process.env.GITHUB_TOKEN, + }; +} + +export function validateCompressor(compressor: string): void { + const deprecationMessage = DEPRECATED_COMPRESSORS[compressor]; + if (deprecationMessage) { + warning(`⚠️ Deprecated: ${deprecationMessage}`); + } + + if (!isBuiltInCompressor(compressor)) { + warning( + `Compressor '${compressor}' is not a built-in compressor. ` + + `Treating as custom npm package or local file.` + ); + } +} + +export const validateJavaCompressor = validateCompressor; + +export { DEPRECATED_COMPRESSORS, JAVA_COMPRESSORS, TYPE_REQUIRED_COMPRESSORS }; diff --git a/packages/action/src/minify.ts b/packages/action/src/minify.ts new file mode 100644 index 000000000..f3fa493e8 --- /dev/null +++ b/packages/action/src/minify.ts @@ -0,0 +1,70 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { minify } from "@node-minify/core"; +import { getFilesizeGzippedRaw, resolveCompressor } from "@node-minify/utils"; +import type { ActionInputs, FileResult, MinifyResult } from "./types.ts"; + +async function getFileSize(filePath: string): Promise { + const stats = await stat(filePath); + return stats.size; +} + +export async function runMinification( + inputs: ActionInputs +): Promise { + const inputPath = resolve(inputs.workingDirectory, inputs.input); + const outputPath = resolve(inputs.workingDirectory, inputs.output); + + const originalSize = await getFileSize(inputPath); + const { compressor, label } = await resolveCompressor(inputs.compressor); + + const startTime = performance.now(); + + await minify({ + compressor, + input: inputPath, + output: outputPath, + ...(inputs.type && { type: inputs.type }), + ...(Object.keys(inputs.options).length > 0 && { + options: inputs.options, + }), + }); + + const endTime = performance.now(); + const timeMs = Math.round(endTime - startTime); + + const minifiedSize = await getFileSize(outputPath); + const reduction = + originalSize > 0 + ? ((originalSize - minifiedSize) / originalSize) * 100 + : 0; + + let gzipSize: number | undefined; + if (inputs.includeGzip) { + gzipSize = await getFilesizeGzippedRaw(outputPath); + } + + const fileResult: FileResult = { + file: inputs.input, + originalSize, + minifiedSize, + reduction, + gzipSize, + timeMs, + }; + + return { + files: [fileResult], + compressor: label, + totalOriginalSize: originalSize, + totalMinifiedSize: minifiedSize, + totalReduction: reduction, + totalTimeMs: timeMs, + }; +} diff --git a/packages/action/src/outputs.ts b/packages/action/src/outputs.ts new file mode 100644 index 000000000..8880dba9a --- /dev/null +++ b/packages/action/src/outputs.ts @@ -0,0 +1,37 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { setOutput } from "@actions/core"; +import type { BenchmarkResult, MinifyResult } from "./types.ts"; + +export function setMinifyOutputs(result: MinifyResult): void { + setOutput("original-size", result.totalOriginalSize); + setOutput("minified-size", result.totalMinifiedSize); + setOutput("reduction-percent", result.totalReduction.toFixed(2)); + setOutput("time-ms", result.totalTimeMs); + setOutput("report-json", JSON.stringify(result)); + + if (result.files.length > 0 && result.files[0]?.gzipSize) { + const totalGzip = result.files.reduce( + (sum, f) => sum + (f.gzipSize || 0), + 0 + ); + setOutput("gzip-size", totalGzip); + } +} + +export function setBenchmarkOutputs(result: BenchmarkResult): void { + if (result.recommended) { + setOutput("benchmark-winner", result.recommended); + } + if (result.bestCompression) { + setOutput("best-compression", result.bestCompression); + } + if (result.bestSpeed) { + setOutput("best-speed", result.bestSpeed); + } + setOutput("benchmark-json", JSON.stringify(result)); +} diff --git a/packages/action/src/reporters/annotations.ts b/packages/action/src/reporters/annotations.ts new file mode 100644 index 000000000..ab5aca8c1 --- /dev/null +++ b/packages/action/src/reporters/annotations.ts @@ -0,0 +1,41 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { error, notice, warning } from "@actions/core"; +import type { MinifyResult } from "../types.ts"; + +const LOW_REDUCTION_THRESHOLD = 20; +const VERY_LOW_REDUCTION_THRESHOLD = 5; + +export function addAnnotations(result: MinifyResult): void { + for (const file of result.files) { + if (file.reduction < VERY_LOW_REDUCTION_THRESHOLD) { + warning( + `Very low compression ratio (${file.reduction.toFixed(1)}%). ` + + `Consider reviewing for dead code or checking if file is already minified.`, + { file: file.file } + ); + } else if (file.reduction < LOW_REDUCTION_THRESHOLD) { + notice( + `Low compression ratio (${file.reduction.toFixed(1)}%). ` + + `File may already be optimized.`, + { file: file.file } + ); + } + + if (file.reduction < 0) { + error( + `Minified file is larger than original (${file.reduction.toFixed(1)}% increase). ` + + `This may indicate an issue with the compressor settings.`, + { file: file.file } + ); + } + } +} + +export function addErrorAnnotation(file: string, errorMsg: string): void { + error(`Minification failed: ${errorMsg}`, { file }); +} diff --git a/packages/action/src/reporters/comment.ts b/packages/action/src/reporters/comment.ts new file mode 100644 index 000000000..fc7149640 --- /dev/null +++ b/packages/action/src/reporters/comment.ts @@ -0,0 +1,89 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { info, warning } from "@actions/core"; +import { context, getOctokit } from "@actions/github"; +import { prettyBytes } from "@node-minify/utils"; +import type { MinifyResult } from "../types.ts"; + +const COMMENT_TAG = ""; + +export async function postPRComment( + result: MinifyResult, + githubToken: string | undefined +): Promise { + if (!githubToken) { + warning("No GitHub token provided, skipping PR comment"); + return; + } + + const prNumber = context.payload.pull_request?.number; + if (!prNumber) { + warning("Not a pull request, skipping PR comment"); + return; + } + + const octokit = getOctokit(githubToken); + const { owner, repo } = context.repo; + + const body = generateCommentBody(result); + + const { data: comments } = await octokit.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber, + }); + + const existingComment = comments.find((c) => c.body?.includes(COMMENT_TAG)); + + if (existingComment) { + await octokit.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body, + }); + info(`Updated existing PR comment #${existingComment.id}`); + } else { + const { data: newComment } = await octokit.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + info(`Created new PR comment #${newComment.id}`); + } +} + +function generateCommentBody(result: MinifyResult): string { + const filesTable = result.files + .map( + (f) => + `| \`${f.file}\` | ${prettyBytes(f.originalSize)} | ${prettyBytes(f.minifiedSize)} | ${f.reduction.toFixed(1)}% |` + ) + .join("\n"); + + return `${COMMENT_TAG} +## 📦 node-minify Report + +| File | Original | Minified | Reduction | +|------|----------|----------|-----------| +${filesTable} + +**Total:** ${prettyBytes(result.totalOriginalSize)} → ${prettyBytes(result.totalMinifiedSize)} (${result.totalReduction.toFixed(1)}% reduction) + +
+Configuration + +- **Compressor:** ${result.compressor} +- **Time:** ${result.totalTimeMs}ms + +
+ +--- +*Generated by [node-minify](https://github.com/srod/node-minify) action* +`; +} diff --git a/packages/action/src/reporters/summary.ts b/packages/action/src/reporters/summary.ts new file mode 100644 index 000000000..7c9b28a4c --- /dev/null +++ b/packages/action/src/reporters/summary.ts @@ -0,0 +1,93 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { summary } from "@actions/core"; +import { prettyBytes } from "@node-minify/utils"; +import type { BenchmarkResult, MinifyResult } from "../types.ts"; + +export async function generateSummary(result: MinifyResult): Promise { + const rows = result.files.map((f) => [ + { data: `\`${f.file}\`` }, + { data: prettyBytes(f.originalSize) }, + { data: prettyBytes(f.minifiedSize) }, + { data: `${f.reduction.toFixed(1)}%` }, + { data: f.gzipSize ? prettyBytes(f.gzipSize) : "-" }, + { data: `${f.timeMs}ms` }, + ]); + + await summary + .addHeading("📦 node-minify Results", 2) + .addTable([ + [ + { data: "File", header: true }, + { data: "Original", header: true }, + { data: "Minified", header: true }, + { data: "Reduction", header: true }, + { data: "Gzip", header: true }, + { data: "Time", header: true }, + ], + ...rows, + ]) + .addBreak() + .addRaw(`**Compressor:** ${result.compressor}`) + .addBreak() + .addRaw( + `**Total:** ${prettyBytes(result.totalOriginalSize)} → ${prettyBytes(result.totalMinifiedSize)} (${result.totalReduction.toFixed(1)}% reduction)` + ) + .write(); +} + +export async function generateBenchmarkSummary( + result: BenchmarkResult +): Promise { + const rows = result.compressors.map((c) => { + if (!c.success) { + return [ + { data: c.compressor }, + { data: "❌ Failed" }, + { data: "-" }, + { data: "-" }, + { data: c.error || "Unknown error" }, + ]; + } + const isRecommended = c.compressor === result.recommended; + const isBestSpeed = c.compressor === result.bestSpeed; + const isBestCompression = c.compressor === result.bestCompression; + + let badge = ""; + if (isRecommended) badge = " 🏆"; + else if (isBestSpeed) badge = " ⚡"; + else if (isBestCompression) badge = " 📦"; + + return [ + { data: `${c.compressor}${badge}` }, + { data: c.size ? prettyBytes(c.size) : "-" }, + { data: c.reduction ? `${c.reduction.toFixed(1)}%` : "-" }, + { data: c.gzipSize ? prettyBytes(c.gzipSize) : "-" }, + { data: c.timeMs ? `${c.timeMs}ms` : "-" }, + ]; + }); + + await summary + .addHeading("🏁 Benchmark Results", 2) + .addRaw( + `**File:** \`${result.file}\` (${prettyBytes(result.originalSize)})` + ) + .addBreak() + .addTable([ + [ + { data: "Compressor", header: true }, + { data: "Size", header: true }, + { data: "Reduction", header: true }, + { data: "Gzip", header: true }, + { data: "Time", header: true }, + ], + ...rows, + ]) + .addBreak() + .addRaw(`**Recommended:** ${result.recommended || "N/A"}`) + .write(); +} diff --git a/packages/action/src/types.ts b/packages/action/src/types.ts new file mode 100644 index 000000000..0b64e76eb --- /dev/null +++ b/packages/action/src/types.ts @@ -0,0 +1,69 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +export interface ActionInputs { + input: string; + output: string; + compressor: string; + type?: "js" | "css"; + options: Record; + reportSummary: boolean; + reportPRComment: boolean; + reportAnnotations: boolean; + benchmark: boolean; + benchmarkCompressors: string[]; + failOnIncrease: boolean; + minReduction: number; + includeGzip: boolean; + workingDirectory: string; + githubToken?: string; +} + +export interface FileResult { + file: string; + originalSize: number; + minifiedSize: number; + reduction: number; + gzipSize?: number; + brotliSize?: number; + timeMs: number; +} + +export interface MinifyResult { + files: FileResult[]; + compressor: string; + totalOriginalSize: number; + totalMinifiedSize: number; + totalReduction: number; + totalTimeMs: number; +} + +export interface BenchmarkCompressorResult { + compressor: string; + success: boolean; + size?: number; + reduction?: number; + gzipSize?: number; + timeMs?: number; + error?: string; +} + +export interface BenchmarkResult { + file: string; + originalSize: number; + compressors: BenchmarkCompressorResult[]; + bestCompression?: string; + bestSpeed?: string; + recommended?: string; +} + +export interface ComparisonResult { + file: string; + baseSize: number | null; + currentSize: number; + change: number | null; + isNew: boolean; +} diff --git a/packages/action/tsconfig.json b/packages/action/tsconfig.json new file mode 100644 index 000000000..8ffe5db9f --- /dev/null +++ b/packages/action/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/action/vitest.config.ts b/packages/action/vitest.config.ts new file mode 100644 index 000000000..e5829d7aa --- /dev/null +++ b/packages/action/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + testTimeout: 30000, + }, +}); diff --git a/packages/benchmark/README.md b/packages/benchmark/README.md index 4a1e6447f..1b143c5a7 100644 --- a/packages/benchmark/README.md +++ b/packages/benchmark/README.md @@ -31,7 +31,7 @@ import { benchmark } from '@node-minify/benchmark'; const results = await benchmark({ input: 'src/app.js', - compressors: ['terser', 'esbuild', 'swc'], + compressors: ['terser', 'esbuild', 'swc', 'oxc'], iterations: 3, includeGzip: true }); @@ -46,7 +46,7 @@ console.log(results.summary.recommended); // Best balance of speed and compressi node-minify benchmark src/app.js # Compare specific compressors -node-minify benchmark src/app.js --compressors terser,esbuild,swc +node-minify benchmark src/app.js --compressors terser,esbuild,swc,oxc # Custom compressors (npm packages or local files) node-minify benchmark src/app.js --compressors terser,./my-compressor.js,my-custom-pkg @@ -66,7 +66,7 @@ node-minify benchmark src/app.js -c terser,esbuild -f markdown | Option | CLI Flag | Description | Default | |--------|----------|-------------|---------| | `input` | `` | File(s) to benchmark | Required | -| `compressors` | `-c, --compressors` | Comma-separated list of compressors | `terser,esbuild,swc` | +| `compressors` | `-c, --compressors` | Comma-separated list of compressors | `terser,esbuild,swc,oxc` | | `iterations` | `-n, --iterations` | Number of iterations | `1` | | `format` | `-f, --format` | Output format: `console`, `json`, `markdown` | `console` | | `output` | `-o, --output` | Output file path | stdout | diff --git a/packages/benchmark/src/runner.ts b/packages/benchmark/src/runner.ts index 09969bfbd..17650a696 100644 --- a/packages/benchmark/src/runner.ts +++ b/packages/benchmark/src/runner.ts @@ -62,7 +62,12 @@ async function benchmarkFile( const originalSize = prettyBytes(originalSizeBytes); const results: CompressorMetrics[] = []; - const compressors = options.compressors || ["terser", "esbuild", "swc"]; + const compressors = options.compressors || [ + "terser", + "esbuild", + "swc", + "oxc", + ]; for (const name of compressors) { if (options.onProgress) { diff --git a/packages/utils/src/compressor-resolver.ts b/packages/utils/src/compressor-resolver.ts index 71cb97225..db171c75c 100644 --- a/packages/utils/src/compressor-resolver.ts +++ b/packages/utils/src/compressor-resolver.ts @@ -29,6 +29,7 @@ const KNOWN_COMPRESSOR_EXPORTS: Record = { crass: "crass", sqwish: "sqwish", "html-minifier": "htmlMinifier", + "minify-html": "minifyHtml", jsonminify: "jsonMinify", imagemin: "imagemin", sharp: "sharp", diff --git a/packages/utils/src/getFilesizeGzippedInBytes.ts b/packages/utils/src/getFilesizeGzippedInBytes.ts index 348db1735..f513bdcfa 100644 --- a/packages/utils/src/getFilesizeGzippedInBytes.ts +++ b/packages/utils/src/getFilesizeGzippedInBytes.ts @@ -9,43 +9,52 @@ import { FileOperationError } from "./error.ts"; import { isValidFile } from "./isValidFile.ts"; import { prettyBytes } from "./prettyBytes.ts"; +/** + * Internal helper to calculate gzipped size of a file using streaming. + * @param file - Path to the file + * @returns Gzipped size in bytes + * @throws {FileOperationError} If file doesn't exist or is not a valid file + * @internal + */ +async function getGzipSize(file: string): Promise { + if (!existsSync(file)) { + throw new FileOperationError( + "access", + file, + new Error("File does not exist") + ); + } + + if (!isValidFile(file)) { + throw new FileOperationError( + "access", + file, + new Error("Path is not a valid file") + ); + } + + const { gzipSizeStream } = await import("gzip-size"); + const source = createReadStream(file); + + return new Promise((resolve, reject) => { + source + .pipe(gzipSizeStream()) + .on("gzip-size", resolve) + .on("error", reject); + }); +} + /** * Get the gzipped file size as a human-readable string. * @param file - Path to the file * @returns Formatted gzipped file size string (e.g., "1.5 kB") - * @throws {FileOperationError} If file doesn't exist or operation fails * @example - * const size = await getFilesizeGzippedInBytes('file.js') - * console.log(size) // '1.5 kB' + * const size = await getFilesizeGzippedInBytes('bundle.js') + * console.log(size) // '12.3 kB' */ export async function getFilesizeGzippedInBytes(file: string): Promise { try { - if (!existsSync(file)) { - throw new FileOperationError( - "access", - file, - new Error("File does not exist") - ); - } - - if (!isValidFile(file)) { - throw new FileOperationError( - "access", - file, - new Error("Path is not a valid file") - ); - } - - const { gzipSizeStream } = await import("gzip-size"); - const source = createReadStream(file); - - const size = await new Promise((resolve, reject) => { - source - .pipe(gzipSizeStream()) - .on("gzip-size", resolve) - .on("error", reject); - }); - + const size = await getGzipSize(file); return prettyBytes(size); } catch (error) { throw new FileOperationError( @@ -55,3 +64,23 @@ export async function getFilesizeGzippedInBytes(file: string): Promise { ); } } + +/** + * Get the gzipped file size in bytes. + * @param file - Path to the file + * @returns Gzipped file size in bytes + * @example + * const bytes = await getFilesizeGzippedRaw('bundle.js') + * console.log(bytes) // 12583 + */ +export async function getFilesizeGzippedRaw(file: string): Promise { + try { + return await getGzipSize(file); + } catch (error) { + throw new FileOperationError( + "get gzipped size of", + file, + error as Error + ); + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index b04d3799d..aca28f76f 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -13,7 +13,10 @@ import { getContentFromFilesAsync, } from "./getContentFromFiles.ts"; import { getFilesizeBrotliInBytes } from "./getFilesizeBrotliInBytes.ts"; -import { getFilesizeGzippedInBytes } from "./getFilesizeGzippedInBytes.ts"; +import { + getFilesizeGzippedInBytes, + getFilesizeGzippedRaw, +} from "./getFilesizeGzippedInBytes.ts"; import { getFilesizeInBytes } from "./getFilesizeInBytes.ts"; import { isImageFile } from "./isImageFile.ts"; import { isValidFile, isValidFileAsync } from "./isValidFile.ts"; @@ -35,6 +38,7 @@ export { getContentFromFilesAsync, getFilesizeBrotliInBytes, getFilesizeGzippedInBytes, + getFilesizeGzippedRaw, getFilesizeInBytes, getKnownExportName, isBuiltInCompressor, From 44736c935411490412ffbbef1d11b911f64db00e Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:16:44 +0100 Subject: [PATCH 02/49] feat(action): add marketplace support and documentation - Create root action.yml for uses: srod/node-minify@v1 syntax - Switch from ncc to Bun bundler for 673KB self-contained bundle - Add release-action.yml workflow for automated releases - Update test-action.yml to build and test the bundled action - Deprecate old composite action at .github/actions/node-minify - Add GitHub Action documentation page to Astro docs site - Add index.d.ts for type exports --- .github/actions/node-minify/README.md | 126 ++++-------- .github/actions/node-minify/action.yml | 17 +- .github/workflows/release-action.yml | 72 +++++++ .github/workflows/test-action.yml | 79 ++++++-- action.yml | 87 ++++++++ docs/src/consts.ts | 1 + docs/src/content/docs/github-action.md | 268 +++++++++++++++++++++++++ packages/action/package.json | 2 +- packages/action/src/index.d.ts | 7 + 9 files changed, 547 insertions(+), 112 deletions(-) create mode 100644 .github/workflows/release-action.yml create mode 100644 action.yml create mode 100644 docs/src/content/docs/github-action.md create mode 100644 packages/action/src/index.d.ts diff --git a/.github/actions/node-minify/README.md b/.github/actions/node-minify/README.md index 0310dc840..7146ae4b9 100644 --- a/.github/actions/node-minify/README.md +++ b/.github/actions/node-minify/README.md @@ -1,41 +1,61 @@ -# node-minify GitHub Action +# node-minify GitHub Action (DEPRECATED) -Minify JavaScript, CSS, and HTML files in your CI/CD pipeline with detailed reporting. +> **This action is deprecated.** Please use the new bundled action instead: +> +> ```yaml +> - uses: srod/node-minify@main +> ``` -## Usage +The new action includes: +- Bundled dependencies (faster startup) +- PR comment reporting +- File annotations +- Benchmark comparison +- Threshold enforcement +- More compressor options -### Basic Example +## Migration + +Replace: ```yaml -- name: Minify JavaScript - uses: srod/node-minify/.github/actions/node-minify@main +- uses: srod/node-minify/.github/actions/node-minify@main + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" +``` + +With: + +```yaml +- uses: srod/node-minify@main with: input: "src/app.js" output: "dist/app.min.js" compressor: "terser" ``` -### With All Options +See [packages/action/README.md](../../../packages/action/README.md) for full documentation. + +--- + +## Legacy Documentation + +The following documentation is for the deprecated composite action. + +### Basic Example ```yaml -- name: Minify with full options - id: minify +- name: Minify JavaScript uses: srod/node-minify/.github/actions/node-minify@main with: input: "src/app.js" output: "dist/app.min.js" - compressor: "esbuild" - type: "js" - options: '{"minify": true}' - report-summary: "true" - include-gzip: "true" - -- name: Show results - run: | - echo "Reduction: ${{ steps.minify.outputs.reduction-percent }}%" + compressor: "terser" ``` -## Inputs +### Inputs | Input | Description | Required | Default | |-------|-------------|----------|---------| @@ -48,30 +68,7 @@ Minify JavaScript, CSS, and HTML files in your CI/CD pipeline with detailed repo | `include-gzip` | Include gzip sizes | No | `true` | | `java-version` | Java version for gcc/yui | No | - | -### Available Compressors - -**JavaScript (no Java required):** -- `terser` (recommended) -- `esbuild` (fastest) -- `swc` -- `oxc` -- `uglify-js` - -**CSS (no Java required):** -- `lightningcss` (recommended) -- `clean-css` -- `cssnano` -- `csso` -- `esbuild` - -**HTML:** -- `html-minifier` - -**Requires Java:** -- `gcc` (Google Closure Compiler) -- `yui` (deprecated) - -## Outputs +### Outputs | Output | Description | |--------|-------------| @@ -84,49 +81,6 @@ Minify JavaScript, CSS, and HTML files in your CI/CD pipeline with detailed repo | `gzip-size-formatted` | Gzipped size formatted | | `time-ms` | Compression time in milliseconds | -## Examples - -### CSS Minification - -```yaml -- name: Minify CSS - uses: srod/node-minify/.github/actions/node-minify@main - with: - input: "src/styles.css" - output: "dist/styles.min.css" - compressor: "lightningcss" - type: "css" -``` - -### Using Google Closure Compiler - -```yaml -- name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: "temurin" - java-version: "17" - -- name: Minify with GCC - uses: srod/node-minify/.github/actions/node-minify@main - with: - input: "src/app.js" - output: "dist/app.min.js" - compressor: "gcc" - options: '{"compilation_level": "ADVANCED_OPTIMIZATIONS"}' -``` - -### HTML Minification - -```yaml -- name: Minify HTML - uses: srod/node-minify/.github/actions/node-minify@main - with: - input: "src/index.html" - output: "dist/index.html" - compressor: "html-minifier" -``` - ## License MIT diff --git a/.github/actions/node-minify/action.yml b/.github/actions/node-minify/action.yml index d52cb84b7..5d1a9eb6c 100644 --- a/.github/actions/node-minify/action.yml +++ b/.github/actions/node-minify/action.yml @@ -1,9 +1,9 @@ -name: "node-minify" -description: "Minify JavaScript, CSS, and HTML files with detailed reporting" +name: "node-minify (deprecated)" +description: "DEPRECATED: Use srod/node-minify@main instead. This composite action will be removed in a future release." author: "srod" branding: icon: "minimize-2" - color: "green" + color: "gray" inputs: input: @@ -67,7 +67,11 @@ outputs: runs: using: "composite" steps: - # Setup Java for gcc/yui compressors (auto-setup with default if java-version not specified) + - name: Deprecation warning + shell: bash + run: | + echo "::warning::This action (.github/actions/node-minify) is DEPRECATED. Please migrate to 'uses: srod/node-minify@main' for the new bundled action with more features (PR comments, annotations, benchmarking)." + - name: Setup Java (for gcc/yui) if: contains(fromJSON('["gcc", "google-closure-compiler", "yui"]'), inputs.compressor) uses: actions/setup-java@v4 @@ -75,7 +79,6 @@ runs: distribution: "temurin" java-version: ${{ inputs.java-version || '17' }} - # Warn about deprecated yui compressor - name: Deprecation warning (yui) if: inputs.compressor == 'yui' shell: bash @@ -121,7 +124,7 @@ runs: shell: bash run: | cat >> $GITHUB_STEP_SUMMARY << EOF - ## 📦 node-minify Results + ## node-minify Results | Metric | Value | |--------|-------| @@ -134,4 +137,6 @@ runs: | **Gzip Size** | ${{ steps.minify.outputs.gzip-size-formatted }} | | **Time** | ${{ steps.minify.outputs.time-ms }}ms | + > **Note:** This action is deprecated. Please migrate to \`uses: srod/node-minify@main\` for enhanced features. + EOF diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml new file mode 100644 index 000000000..fa3738f1b --- /dev/null +++ b/.github/workflows/release-action.yml @@ -0,0 +1,72 @@ +name: Release GitHub Action + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Tag to release (e.g., v1.0.0)" + required: true + type: string + +permissions: + contents: write + +jobs: + release-action: + name: Build and Release Action + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.5" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build action + run: bun run build + working-directory: packages/action + + - name: Get version + id: version + run: | + if [ -n "${{ github.event.inputs.tag }}" ]; then + VERSION="${{ github.event.inputs.tag }}" + else + VERSION="${{ github.ref_name }}" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "major=$(echo $VERSION | cut -d. -f1)" >> $GITHUB_OUTPUT + + - name: Update major version tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Force update the major version tag (e.g., v1) + MAJOR="${{ steps.version.outputs.major }}" + git tag -fa "$MAJOR" -m "Update $MAJOR tag to ${{ steps.version.outputs.version }}" + git push origin "$MAJOR" --force + + - name: Create action release artifact + run: | + mkdir -p release-action + cp action.yml release-action/ + cp -r packages/action/dist release-action/ + cp packages/action/README.md release-action/ + cd release-action + zip -r ../node-minify-action-${{ steps.version.outputs.version }}.zip . + + - name: Upload release artifact + if: github.event_name == 'release' + uses: softprops/action-gh-release@v2 + with: + files: node-minify-action-${{ steps.version.outputs.version }}.zip diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 5959f6d31..709855113 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -2,23 +2,57 @@ name: Test node-minify Action on: push: - branches: [feature/github-action] + branches: [main, develop, feature/github-action-package] paths: - - ".github/actions/node-minify/**" + - "packages/action/**" + - "action.yml" - ".github/workflows/test-action.yml" pull_request: paths: - - ".github/actions/node-minify/**" + - "packages/action/**" + - "action.yml" - ".github/workflows/test-action.yml" workflow_dispatch: jobs: + build-action: + name: Build Action + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.5" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build action + run: bun run build + working-directory: packages/action + + - name: Upload built action + uses: actions/upload-artifact@v4 + with: + name: action-dist + path: packages/action/dist/ + retention-days: 1 + test-js-minification: name: Test JS Minification runs-on: ubuntu-latest + needs: build-action steps: - uses: actions/checkout@v4 + - name: Download built action + uses: actions/download-artifact@v4 + with: + name: action-dist + path: packages/action/dist/ + - name: Create test file run: | mkdir -p test-action @@ -38,7 +72,7 @@ jobs: - name: Minify with terser id: minify-terser - uses: ./.github/actions/node-minify + uses: ./ with: input: "test-action/input.js" output: "test-action/output.min.js" @@ -46,16 +80,16 @@ jobs: - name: Verify terser outputs env: - ORIGINAL_SIZE: ${{ steps.minify-terser.outputs.original-size-formatted }} - MINIFIED_SIZE: ${{ steps.minify-terser.outputs.minified-size-formatted }} + ORIGINAL_SIZE: ${{ steps.minify-terser.outputs.original-size }} + MINIFIED_SIZE: ${{ steps.minify-terser.outputs.minified-size }} REDUCTION_PERCENT: ${{ steps.minify-terser.outputs.reduction-percent }} - GZIP_SIZE: ${{ steps.minify-terser.outputs.gzip-size-formatted }} + GZIP_SIZE: ${{ steps.minify-terser.outputs.gzip-size }} TIME_MS: ${{ steps.minify-terser.outputs.time-ms }} run: | - echo "Original size: $ORIGINAL_SIZE" - echo "Minified size: $MINIFIED_SIZE" + echo "Original size: $ORIGINAL_SIZE bytes" + echo "Minified size: $MINIFIED_SIZE bytes" echo "Reduction: $REDUCTION_PERCENT%" - echo "Gzip size: $GZIP_SIZE" + echo "Gzip size: $GZIP_SIZE bytes" echo "Time: ${TIME_MS}ms" if [ ! -f "test-action/output.min.js" ]; then @@ -73,7 +107,7 @@ jobs: - name: Minify with esbuild id: minify-esbuild - uses: ./.github/actions/node-minify + uses: ./ with: input: "test-action/input.js" output: "test-action/output.esbuild.js" @@ -82,24 +116,31 @@ jobs: - name: Compare results env: - TERSER_SIZE: ${{ steps.minify-terser.outputs.minified-size-formatted }} + TERSER_SIZE: ${{ steps.minify-terser.outputs.minified-size }} TERSER_REDUCTION: ${{ steps.minify-terser.outputs.reduction-percent }} - ESBUILD_SIZE: ${{ steps.minify-esbuild.outputs.minified-size-formatted }} + ESBUILD_SIZE: ${{ steps.minify-esbuild.outputs.minified-size }} ESBUILD_REDUCTION: ${{ steps.minify-esbuild.outputs.reduction-percent }} run: | echo "## Comparison" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Compressor | Minified Size | Reduction |" >> $GITHUB_STEP_SUMMARY echo "|------------|---------------|-----------|" >> $GITHUB_STEP_SUMMARY - echo "| terser | $TERSER_SIZE | $TERSER_REDUCTION% |" >> $GITHUB_STEP_SUMMARY - echo "| esbuild | $ESBUILD_SIZE | $ESBUILD_REDUCTION% |" >> $GITHUB_STEP_SUMMARY + echo "| terser | ${TERSER_SIZE} bytes | ${TERSER_REDUCTION}% |" >> $GITHUB_STEP_SUMMARY + echo "| esbuild | ${ESBUILD_SIZE} bytes | ${ESBUILD_REDUCTION}% |" >> $GITHUB_STEP_SUMMARY test-css-minification: name: Test CSS Minification runs-on: ubuntu-latest + needs: build-action steps: - uses: actions/checkout@v4 + - name: Download built action + uses: actions/download-artifact@v4 + with: + name: action-dist + path: packages/action/dist/ + - name: Create test CSS file run: | mkdir -p test-action @@ -129,7 +170,7 @@ jobs: - name: Minify CSS with lightningcss id: minify-css - uses: ./.github/actions/node-minify + uses: ./ with: input: "test-action/input.css" output: "test-action/output.min.css" @@ -138,8 +179,8 @@ jobs: - name: Verify CSS output run: | - echo "Original: ${{ steps.minify-css.outputs.original-size-formatted }}" - echo "Minified: ${{ steps.minify-css.outputs.minified-size-formatted }}" + echo "Original: ${{ steps.minify-css.outputs.original-size }} bytes" + echo "Minified: ${{ steps.minify-css.outputs.minified-size }} bytes" echo "Reduction: ${{ steps.minify-css.outputs.reduction-percent }}%" if [ "$(echo '${{ steps.minify-css.outputs.reduction-percent }} > 0' | bc -l)" -ne 1 ]; then @@ -155,4 +196,4 @@ jobs: needs: [test-js-minification, test-css-minification] steps: - name: All tests passed - run: echo "✅ All node-minify action tests passed!" + run: echo "All node-minify action tests passed!" diff --git a/action.yml b/action.yml new file mode 100644 index 000000000..3900c1899 --- /dev/null +++ b/action.yml @@ -0,0 +1,87 @@ +name: "node-minify" +description: "Minify JavaScript, CSS, and HTML files with detailed reporting and PR comments" +author: "srod" +branding: + icon: "minimize-2" + color: "green" + +inputs: + input: + description: "Files to minify (glob pattern or path)" + required: true + output: + description: "Output file path" + required: true + compressor: + description: | + Compressor to use. + Recommended: terser, esbuild, swc, oxc (fast, no Java). + Note: 'gcc' and 'yui' require Java (pre-installed on GitHub runners). + required: false + default: "terser" + type: + description: "File type: js or css (required for esbuild, lightningcss, yui)" + required: false + options: + description: "Compressor-specific options (JSON string)" + required: false + default: "{}" + report-summary: + description: "Add results to job summary" + required: false + default: "true" + report-pr-comment: + description: "Post results as PR comment" + required: false + default: "false" + report-annotations: + description: "Add file annotations for warnings" + required: false + default: "false" + benchmark: + description: "Run benchmark comparison across compressors" + required: false + default: "false" + benchmark-compressors: + description: "Compressors to compare (comma-separated)" + required: false + default: "terser,esbuild,swc,oxc" + fail-on-increase: + description: "Fail if minified size is larger than original" + required: false + default: "false" + min-reduction: + description: "Minimum reduction % required (0-100)" + required: false + default: "0" + include-gzip: + description: "Include gzip sizes in report" + required: false + default: "true" + working-directory: + description: "Working directory for file operations" + required: false + default: "." + github-token: + description: "GitHub token for PR comments" + required: false + +outputs: + original-size: + description: "Original file size in bytes" + minified-size: + description: "Minified file size in bytes" + reduction-percent: + description: "Size reduction percentage" + gzip-size: + description: "Gzipped size in bytes" + time-ms: + description: "Compression time in milliseconds" + report-json: + description: "Full report as JSON string" + benchmark-winner: + description: "Best compressor from benchmark (if run)" + +runs: + using: "node20" + main: "packages/action/dist/index.js" diff --git a/docs/src/consts.ts b/docs/src/consts.ts index 5e525fdca..c4f7fbab7 100644 --- a/docs/src/consts.ts +++ b/docs/src/consts.ts @@ -12,6 +12,7 @@ export const SIDEBAR: Sidebar = { { text: "Custom Compressors", link: "custom-compressors" }, { text: "Options", link: "options" }, { text: "CLI", link: "cli" }, + { text: "GitHub Action", link: "github-action" }, { text: "Benchmark", link: "benchmark" }, ], Compressors: [ diff --git a/docs/src/content/docs/github-action.md b/docs/src/content/docs/github-action.md new file mode 100644 index 000000000..ca47b942d --- /dev/null +++ b/docs/src/content/docs/github-action.md @@ -0,0 +1,268 @@ +--- +title: "GitHub Action" +description: "Use node-minify in your CI/CD pipelines with the official GitHub Action" +--- + +Minify JavaScript, CSS, and HTML files directly in your GitHub workflows with detailed reporting. + +## Features + +- Bundled dependencies (fast startup) +- PR comment reporting +- Job summary with compression stats +- File annotations for warnings +- Benchmark comparison across compressors +- Threshold enforcement (fail on size increase) +- Support for 22+ compressors + +## Quick Start + +```yaml +- name: Minify JavaScript + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" +``` + +## Usage Examples + +### Basic Minification + +```yaml +name: Build +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Minify JS + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" +``` + +### With PR Comments + +```yaml +- name: Minify and Report + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "esbuild" + type: "js" + report-summary: "true" + report-pr-comment: "true" + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +### CSS Minification + +```yaml +- name: Minify CSS + uses: srod/node-minify@v1 + with: + input: "src/styles.css" + output: "dist/styles.min.css" + compressor: "lightningcss" + type: "css" +``` + +### With Quality Gates + +```yaml +- name: Minify with Thresholds + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" + fail-on-increase: "true" + min-reduction: "20" +``` + +### Benchmark Comparison + +```yaml +- name: Benchmark Compressors + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" + benchmark: "true" + benchmark-compressors: "terser,esbuild,swc,oxc" +``` + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `input` | Files to minify (glob pattern or path) | Yes | - | +| `output` | Output file path | Yes | - | +| `compressor` | Compressor to use | No | `terser` | +| `type` | File type: `js` or `css` | No | - | +| `options` | Compressor options (JSON) | No | `{}` | +| `report-summary` | Add results to job summary | No | `true` | +| `report-pr-comment` | Post results as PR comment | No | `false` | +| `report-annotations` | Add file annotations | No | `false` | +| `benchmark` | Run benchmark comparison | No | `false` | +| `benchmark-compressors` | Compressors to compare | No | `terser,esbuild,swc,oxc` | +| `fail-on-increase` | Fail if size increases | No | `false` | +| `min-reduction` | Minimum reduction % (0-100) | No | `0` | +| `include-gzip` | Include gzip sizes | No | `true` | +| `working-directory` | Working directory | No | `.` | +| `github-token` | Token for PR comments | No | - | + +### Type Parameter + +The `type` parameter is **required** for: +- `esbuild` (specify `js` or `css`) +- `lightningcss` (specify `css`) +- `yui` (specify `js` or `css`) + +### Available Compressors + +**JavaScript:** +- `terser` (recommended) +- `esbuild` (fastest, requires `type: js`) +- `swc` +- `oxc` +- `uglify-js` +- `google-closure-compiler` / `gcc` (requires Java) + +**CSS:** +- `lightningcss` (recommended, requires `type: css`) +- `clean-css` +- `cssnano` +- `csso` +- `esbuild` (requires `type: css`) + +**HTML:** +- `html-minifier` +- `minify-html` + +**JSON:** +- `jsonminify` + +**Image:** +- `sharp` (WebP/AVIF conversion) +- `svgo` (SVG optimization) +- `imagemin` (PNG/JPEG/GIF) + +**Other:** +- `no-compress` (passthrough) + +## Outputs + +| Output | Description | +|--------|-------------| +| `original-size` | Original size in bytes | +| `minified-size` | Minified size in bytes | +| `reduction-percent` | Size reduction percentage | +| `gzip-size` | Gzipped size in bytes | +| `time-ms` | Compression time in ms | +| `report-json` | Full report as JSON | +| `benchmark-winner` | Best compressor (if benchmark run) | + +### Using Outputs + +```yaml +- name: Minify + id: minify + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" + +- name: Show Results + run: | + echo "Original: ${{ steps.minify.outputs.original-size }} bytes" + echo "Minified: ${{ steps.minify.outputs.minified-size }} bytes" + echo "Reduction: ${{ steps.minify.outputs.reduction-percent }}%" +``` + +## Advanced Examples + +### Google Closure Compiler + +```yaml +- name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + +- name: Minify with GCC + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "gcc" + options: '{"compilation_level": "ADVANCED_OPTIMIZATIONS"}' +``` + +### HTML Minification + +```yaml +- name: Minify HTML + uses: srod/node-minify@v1 + with: + input: "src/index.html" + output: "dist/index.html" + compressor: "html-minifier" + options: '{"collapseWhitespace": true, "removeComments": true}' +``` + +### Multiple Files + +```yaml +- name: Minify JS bundle + uses: srod/node-minify@v1 + with: + input: "src/**/*.js" + output: "dist/bundle.min.js" + compressor: "terser" + +- name: Minify CSS bundle + uses: srod/node-minify@v1 + with: + input: "src/**/*.css" + output: "dist/styles.min.css" + compressor: "lightningcss" + type: "css" +``` + +## Job Summary + +When `report-summary` is enabled (default), the action adds a detailed summary to your workflow run showing: + +- Input/output files +- Original and minified sizes +- Compression percentage +- Gzip size +- Processing time + +## PR Comments + +Enable `report-pr-comment` to automatically post compression results as a comment on pull requests. Requires `github-token` to be set. + +## Deprecation Notices + +The following compressors are deprecated and will emit warnings: + +| Deprecated | Use Instead | +|------------|-------------| +| `babel-minify` | `terser` | +| `uglify-es` | `terser` | +| `yui` | `terser` (JS) or `lightningcss` (CSS) | +| `crass` | `lightningcss` or `clean-css` | +| `sqwish` | `lightningcss` or `clean-css` | diff --git a/packages/action/package.json b/packages/action/package.json index 62052662a..b19ca7544 100644 --- a/packages/action/package.json +++ b/packages/action/package.json @@ -40,7 +40,7 @@ "url": "https://github.com/srod/node-minify/issues" }, "scripts": { - "build": "tsdown src/index.ts", + "build": "bun build src/index.ts --outdir dist --target node --format esm --bundle --minify && cp src/index.d.ts dist/index.d.ts 2>/dev/null || true", "build:ncc": "ncc build src/index.ts -o dist --source-map --license licenses.txt", "format:check": "biome check .", "lint": "biome lint .", diff --git a/packages/action/src/index.d.ts b/packages/action/src/index.d.ts new file mode 100644 index 000000000..7e2445891 --- /dev/null +++ b/packages/action/src/index.d.ts @@ -0,0 +1,7 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +export * from "./types.ts"; From 4c237515129bc9fd492072489ac743bdef35a134 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:21:29 +0100 Subject: [PATCH 03/49] fix(action): build dependencies before bundling action The action build requires @node-minify/core and @node-minify/utils to be built first since Bun's bundler resolves workspace dependencies. --- .github/workflows/release-action.yml | 3 +++ .github/workflows/test-action.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index fa3738f1b..d136c16dc 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -31,6 +31,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Build dependencies + run: bun run build:deps + - name: Build action run: bun run build working-directory: packages/action diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 709855113..e93ffaac0 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -29,6 +29,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Build dependencies + run: bun run build:deps + - name: Build action run: bun run build working-directory: packages/action From 22d7df6845b4b7e840be0e413aededb03ef12729 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:23:16 +0100 Subject: [PATCH 04/49] fix(action): also build @node-minify/core before bundling The action imports from @node-minify/core for the minify function. --- .github/workflows/release-action.yml | 4 +++- .github/workflows/test-action.yml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index d136c16dc..b7ed7999d 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -32,7 +32,9 @@ jobs: run: bun install --frozen-lockfile - name: Build dependencies - run: bun run build:deps + run: | + bun run build:deps + bun run --filter '@node-minify/core' build - name: Build action run: bun run build diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index e93ffaac0..edc00c6f4 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -30,7 +30,9 @@ jobs: run: bun install --frozen-lockfile - name: Build dependencies - run: bun run build:deps + run: | + bun run build:deps + bun run --filter '@node-minify/core' build - name: Build action run: bun run build From f20d288f8cc627c602f814c55cf71808f76e70bc Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:25:12 +0100 Subject: [PATCH 05/49] fix(action): install compressor packages in test jobs The bundled action dynamically imports compressor packages at runtime, so they need to be installed in the test environment. --- .github/workflows/test-action.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index edc00c6f4..09f67501d 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -58,6 +58,14 @@ jobs: name: action-dist path: packages/action/dist/ + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install compressor packages + run: npm install @node-minify/terser @node-minify/esbuild + - name: Create test file run: | mkdir -p test-action @@ -146,6 +154,14 @@ jobs: name: action-dist path: packages/action/dist/ + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install compressor packages + run: npm install @node-minify/lightningcss + - name: Create test CSS file run: | mkdir -p test-action From f9c4d25d25ae1de5a8057c33342715b84de55ebe Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:27:16 +0100 Subject: [PATCH 06/49] fix(action): install compressors in temp dir to avoid workspace: protocol npm fails when run in repo root due to workspace:* dependencies in package.json. --- .github/workflows/test-action.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 09f67501d..dc3ee4c59 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -64,7 +64,11 @@ jobs: node-version: "20" - name: Install compressor packages - run: npm install @node-minify/terser @node-minify/esbuild + run: | + mkdir -p /tmp/compressors && cd /tmp/compressors + npm init -y + npm install @node-minify/terser @node-minify/esbuild + echo "NODE_PATH=/tmp/compressors/node_modules" >> $GITHUB_ENV - name: Create test file run: | @@ -160,7 +164,11 @@ jobs: node-version: "20" - name: Install compressor packages - run: npm install @node-minify/lightningcss + run: | + mkdir -p /tmp/compressors && cd /tmp/compressors + npm init -y + npm install @node-minify/lightningcss + echo "NODE_PATH=/tmp/compressors/node_modules" >> $GITHUB_ENV - name: Create test CSS file run: | From 7b41637f7086d419dbb2fbf4c40d8b519a1ed114 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:29:06 +0100 Subject: [PATCH 07/49] fix(action): copy compressor packages to repo node_modules Node's import() looks in the repo's node_modules, not in NODE_PATH. --- .github/workflows/test-action.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index dc3ee4c59..e40c08164 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -68,7 +68,8 @@ jobs: mkdir -p /tmp/compressors && cd /tmp/compressors npm init -y npm install @node-minify/terser @node-minify/esbuild - echo "NODE_PATH=/tmp/compressors/node_modules" >> $GITHUB_ENV + # Copy packages to repo node_modules so action can find them + cp -r /tmp/compressors/node_modules/@node-minify $GITHUB_WORKSPACE/node_modules/ - name: Create test file run: | @@ -168,7 +169,8 @@ jobs: mkdir -p /tmp/compressors && cd /tmp/compressors npm init -y npm install @node-minify/lightningcss - echo "NODE_PATH=/tmp/compressors/node_modules" >> $GITHUB_ENV + # Copy packages to repo node_modules so action can find them + cp -r /tmp/compressors/node_modules/@node-minify $GITHUB_WORKSPACE/node_modules/ - name: Create test CSS file run: | From 7d79a5a476acfee405ed08fd118c8ca6b0dbcc9c Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:33:47 +0100 Subject: [PATCH 08/49] fix(action): ensure node_modules dir exists and copy all deps Also copy the compressor's own dependencies (terser, esbuild, lightningcss). --- .github/workflows/test-action.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index e40c08164..6249905d5 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -69,7 +69,13 @@ jobs: npm init -y npm install @node-minify/terser @node-minify/esbuild # Copy packages to repo node_modules so action can find them - cp -r /tmp/compressors/node_modules/@node-minify $GITHUB_WORKSPACE/node_modules/ + mkdir -p $GITHUB_WORKSPACE/node_modules/@node-minify + cp -r /tmp/compressors/node_modules/@node-minify/* $GITHUB_WORKSPACE/node_modules/@node-minify/ + # Also copy terser and esbuild themselves (direct deps) + cp -r /tmp/compressors/node_modules/terser $GITHUB_WORKSPACE/node_modules/ || true + cp -r /tmp/compressors/node_modules/esbuild $GITHUB_WORKSPACE/node_modules/ || true + # Debug: verify packages exist + ls -la $GITHUB_WORKSPACE/node_modules/@node-minify/ - name: Create test file run: | @@ -170,7 +176,12 @@ jobs: npm init -y npm install @node-minify/lightningcss # Copy packages to repo node_modules so action can find them - cp -r /tmp/compressors/node_modules/@node-minify $GITHUB_WORKSPACE/node_modules/ + mkdir -p $GITHUB_WORKSPACE/node_modules/@node-minify + cp -r /tmp/compressors/node_modules/@node-minify/* $GITHUB_WORKSPACE/node_modules/@node-minify/ + # Also copy lightningcss itself (direct dep) + cp -r /tmp/compressors/node_modules/lightningcss $GITHUB_WORKSPACE/node_modules/ || true + # Debug: verify packages exist + ls -la $GITHUB_WORKSPACE/node_modules/@node-minify/ - name: Create test CSS file run: | From b1c51e8788c368fb9874692990d57c85af076504 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:38:25 +0100 Subject: [PATCH 09/49] fix(action): copy deps to packages/action/node_modules for resolution --- .github/workflows/test-action.yml | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 6249905d5..89f95071f 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -68,14 +68,16 @@ jobs: mkdir -p /tmp/compressors && cd /tmp/compressors npm init -y npm install @node-minify/terser @node-minify/esbuild - # Copy packages to repo node_modules so action can find them + # Copy packages to multiple locations where Node might look mkdir -p $GITHUB_WORKSPACE/node_modules/@node-minify + mkdir -p $GITHUB_WORKSPACE/packages/action/node_modules/@node-minify cp -r /tmp/compressors/node_modules/@node-minify/* $GITHUB_WORKSPACE/node_modules/@node-minify/ - # Also copy terser and esbuild themselves (direct deps) + cp -r /tmp/compressors/node_modules/@node-minify/* $GITHUB_WORKSPACE/packages/action/node_modules/@node-minify/ + # Also copy terser and esbuild themselves cp -r /tmp/compressors/node_modules/terser $GITHUB_WORKSPACE/node_modules/ || true cp -r /tmp/compressors/node_modules/esbuild $GITHUB_WORKSPACE/node_modules/ || true - # Debug: verify packages exist - ls -la $GITHUB_WORKSPACE/node_modules/@node-minify/ + cp -r /tmp/compressors/node_modules/terser $GITHUB_WORKSPACE/packages/action/node_modules/ || true + cp -r /tmp/compressors/node_modules/esbuild $GITHUB_WORKSPACE/packages/action/node_modules/ || true - name: Create test file run: | @@ -175,13 +177,14 @@ jobs: mkdir -p /tmp/compressors && cd /tmp/compressors npm init -y npm install @node-minify/lightningcss - # Copy packages to repo node_modules so action can find them + # Copy packages to multiple locations where Node might look mkdir -p $GITHUB_WORKSPACE/node_modules/@node-minify + mkdir -p $GITHUB_WORKSPACE/packages/action/node_modules/@node-minify cp -r /tmp/compressors/node_modules/@node-minify/* $GITHUB_WORKSPACE/node_modules/@node-minify/ + cp -r /tmp/compressors/node_modules/@node-minify/* $GITHUB_WORKSPACE/packages/action/node_modules/@node-minify/ # Also copy lightningcss itself (direct dep) cp -r /tmp/compressors/node_modules/lightningcss $GITHUB_WORKSPACE/node_modules/ || true - # Debug: verify packages exist - ls -la $GITHUB_WORKSPACE/node_modules/@node-minify/ + cp -r /tmp/compressors/node_modules/lightningcss $GITHUB_WORKSPACE/packages/action/node_modules/ || true - name: Create test CSS file run: | From c9454d8a03d01202408197bcb6b3a16529cec0ed Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:40:15 +0100 Subject: [PATCH 10/49] fix(action): brute force copy of node_modules to all possible locations --- .github/workflows/test-action.yml | 42 ++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 89f95071f..f81541b58 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -68,16 +68,28 @@ jobs: mkdir -p /tmp/compressors && cd /tmp/compressors npm init -y npm install @node-minify/terser @node-minify/esbuild - # Copy packages to multiple locations where Node might look + + # Prepare destination directories mkdir -p $GITHUB_WORKSPACE/node_modules/@node-minify mkdir -p $GITHUB_WORKSPACE/packages/action/node_modules/@node-minify - cp -r /tmp/compressors/node_modules/@node-minify/* $GITHUB_WORKSPACE/node_modules/@node-minify/ - cp -r /tmp/compressors/node_modules/@node-minify/* $GITHUB_WORKSPACE/packages/action/node_modules/@node-minify/ - # Also copy terser and esbuild themselves - cp -r /tmp/compressors/node_modules/terser $GITHUB_WORKSPACE/node_modules/ || true - cp -r /tmp/compressors/node_modules/esbuild $GITHUB_WORKSPACE/node_modules/ || true - cp -r /tmp/compressors/node_modules/terser $GITHUB_WORKSPACE/packages/action/node_modules/ || true - cp -r /tmp/compressors/node_modules/esbuild $GITHUB_WORKSPACE/packages/action/node_modules/ || true + mkdir -p $GITHUB_WORKSPACE/packages/action/dist/node_modules/@node-minify + + # Copy EVERYTHING from temp node_modules to all possible locations + echo "Copying to root node_modules..." + cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/node_modules/ + + echo "Copying to packages/action/node_modules..." + cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/packages/action/node_modules/ + + echo "Copying to packages/action/dist/node_modules..." + cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/packages/action/dist/node_modules/ + + # Debug + echo "Checking @node-minify/terser existence:" + find $GITHUB_WORKSPACE -name terser -type d | grep node_modules + + echo "Checking package.json of installed terser:" + cat $GITHUB_WORKSPACE/node_modules/@node-minify/terser/package.json || echo "Not found in root" - name: Create test file run: | @@ -177,14 +189,16 @@ jobs: mkdir -p /tmp/compressors && cd /tmp/compressors npm init -y npm install @node-minify/lightningcss - # Copy packages to multiple locations where Node might look + + # Prepare destination directories mkdir -p $GITHUB_WORKSPACE/node_modules/@node-minify mkdir -p $GITHUB_WORKSPACE/packages/action/node_modules/@node-minify - cp -r /tmp/compressors/node_modules/@node-minify/* $GITHUB_WORKSPACE/node_modules/@node-minify/ - cp -r /tmp/compressors/node_modules/@node-minify/* $GITHUB_WORKSPACE/packages/action/node_modules/@node-minify/ - # Also copy lightningcss itself (direct dep) - cp -r /tmp/compressors/node_modules/lightningcss $GITHUB_WORKSPACE/node_modules/ || true - cp -r /tmp/compressors/node_modules/lightningcss $GITHUB_WORKSPACE/packages/action/node_modules/ || true + mkdir -p $GITHUB_WORKSPACE/packages/action/dist/node_modules/@node-minify + + # Copy EVERYTHING + cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/node_modules/ + cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/packages/action/node_modules/ + cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/packages/action/dist/node_modules/ - name: Create test CSS file run: | From 7dcf1182bf91a56d892f6d0b76fd949aaa0a29ae Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:43:09 +0100 Subject: [PATCH 11/49] refactor(action): cleanup test workflow installation steps Copying to root node_modules is sufficient as long as we copy ALL packages. --- .github/workflows/test-action.yml | 31 +++---------------------------- 1 file changed, 3 insertions(+), 28 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index f81541b58..ba6aafdf4 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -69,27 +69,9 @@ jobs: npm init -y npm install @node-minify/terser @node-minify/esbuild - # Prepare destination directories - mkdir -p $GITHUB_WORKSPACE/node_modules/@node-minify - mkdir -p $GITHUB_WORKSPACE/packages/action/node_modules/@node-minify - mkdir -p $GITHUB_WORKSPACE/packages/action/dist/node_modules/@node-minify - - # Copy EVERYTHING from temp node_modules to all possible locations - echo "Copying to root node_modules..." + # Copy all installed packages (including dependencies) to root node_modules + # This ensures that both @node-minify/* wrappers AND their dependencies (terser, esbuild) are available cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/node_modules/ - - echo "Copying to packages/action/node_modules..." - cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/packages/action/node_modules/ - - echo "Copying to packages/action/dist/node_modules..." - cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/packages/action/dist/node_modules/ - - # Debug - echo "Checking @node-minify/terser existence:" - find $GITHUB_WORKSPACE -name terser -type d | grep node_modules - - echo "Checking package.json of installed terser:" - cat $GITHUB_WORKSPACE/node_modules/@node-minify/terser/package.json || echo "Not found in root" - name: Create test file run: | @@ -190,15 +172,8 @@ jobs: npm init -y npm install @node-minify/lightningcss - # Prepare destination directories - mkdir -p $GITHUB_WORKSPACE/node_modules/@node-minify - mkdir -p $GITHUB_WORKSPACE/packages/action/node_modules/@node-minify - mkdir -p $GITHUB_WORKSPACE/packages/action/dist/node_modules/@node-minify - - # Copy EVERYTHING + # Copy all installed packages to root node_modules cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/node_modules/ - cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/packages/action/node_modules/ - cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/packages/action/dist/node_modules/ - name: Create test CSS file run: | From bb7b638b94e6fef0f14d9853c91ccf4abd288720 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:47:58 +0100 Subject: [PATCH 12/49] fix(action): ensure root node_modules directory exists before copying This prevents the 'No such file or directory' error while keeping the installation clean. --- .github/workflows/test-action.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index ba6aafdf4..faefc3fbf 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -69,8 +69,8 @@ jobs: npm init -y npm install @node-minify/terser @node-minify/esbuild - # Copy all installed packages (including dependencies) to root node_modules - # This ensures that both @node-minify/* wrappers AND their dependencies (terser, esbuild) are available + # Create node_modules and copy packages + mkdir -p $GITHUB_WORKSPACE/node_modules cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/node_modules/ - name: Create test file @@ -172,7 +172,8 @@ jobs: npm init -y npm install @node-minify/lightningcss - # Copy all installed packages to root node_modules + # Create node_modules and copy packages + mkdir -p $GITHUB_WORKSPACE/node_modules cp -r /tmp/compressors/node_modules/* $GITHUB_WORKSPACE/node_modules/ - name: Create test CSS file From 0a510254a8da5e880805aa97557dcd52f9b2b7ab Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 22:53:43 +0100 Subject: [PATCH 13/49] chore: add changeset for action and utils --- .changeset/fluffy-eagles-sing.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/fluffy-eagles-sing.md diff --git a/.changeset/fluffy-eagles-sing.md b/.changeset/fluffy-eagles-sing.md new file mode 100644 index 000000000..d8d64a984 --- /dev/null +++ b/.changeset/fluffy-eagles-sing.md @@ -0,0 +1,8 @@ +--- +"@node-minify/utils": minor +"@node-minify/benchmark": minor +"@node-minify/docs": patch +--- + +feat: add `getFilesizeGzippedRaw` utility and update benchmark defaults +feat(action): launch `@node-minify/action` GitHub Action From abbfce56576983953ecd64071e56d71f7a1ff764 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 23:05:07 +0100 Subject: [PATCH 14/49] feat(action): extract threshold logic and improve PR reporter pagination --- .github/workflows/release-action.yml | 9 ++++-- AGENTS.md | 6 ++-- packages/action/README.md | 6 ++-- packages/action/__tests__/action.test.ts | 29 ++++++++++++++------ packages/action/src/checks.ts | 22 +++++++++++++++ packages/action/src/index.ts | 18 +++--------- packages/action/src/reporters/annotations.ts | 16 +++++------ packages/action/src/reporters/comment.ts | 2 +- packages/action/src/reporters/summary.ts | 8 +++--- scripts/check-published.ts | 5 +++- 10 files changed, 74 insertions(+), 47 deletions(-) create mode 100644 packages/action/src/checks.ts diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index b7ed7999d..c2bd401b9 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -42,11 +42,14 @@ jobs: - name: Get version id: version + env: + INPUT_TAG: ${{ github.event.inputs.tag }} + REF_NAME: ${{ github.ref_name }} run: | - if [ -n "${{ github.event.inputs.tag }}" ]; then - VERSION="${{ github.event.inputs.tag }}" + if [ -n "$INPUT_TAG" ]; then + VERSION="$INPUT_TAG" else - VERSION="${{ github.ref_name }}" + VERSION="$REF_NAME" fi echo "version=$VERSION" >> $GITHUB_OUTPUT echo "major=$(echo $VERSION | cut -d. -f1)" >> $GITHUB_OUTPUT diff --git a/AGENTS.md b/AGENTS.md index 22539c172..6cfc1fff1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -299,7 +299,7 @@ The repository includes a reusable GitHub Action at `.github/actions/node-minify ### Usage ```yaml -- uses: srod/node-minify/.github/actions/node-minify@main +- uses: srod/node-minify@v1 with: input: "src/app.js" output: "dist/app.min.js" @@ -315,8 +315,8 @@ The repository includes a reusable GitHub Action at `.github/actions/node-minify ### Files | File | Purpose | |------|---------| -| `action.yml` | Action definition, inputs/outputs, composite steps | -| `minify.ts` | Bun script that runs minification, writes GitHub outputs | +| `action.yml` | Action definition, inputs/outputs | +| `packages/action/` | Source code for the action (built to `packages/action/dist/index.js`) | ## CI/CD Workflows diff --git a/packages/action/README.md b/packages/action/README.md index 1721eeee5..ee59f2511 100644 --- a/packages/action/README.md +++ b/packages/action/README.md @@ -17,7 +17,7 @@ GitHub Action for minifying JavaScript, CSS, and HTML files with detailed report ```yaml - name: Minify JavaScript - uses: srod/node-minify/packages/action@main + uses: srod/node-minify@v1 with: input: "src/app.js" output: "dist/app.min.js" @@ -28,7 +28,7 @@ GitHub Action for minifying JavaScript, CSS, and HTML files with detailed report ```yaml - name: Minify and Report - uses: srod/node-minify/packages/action@main + uses: srod/node-minify@v1 with: input: "src/app.js" output: "dist/app.min.js" @@ -43,7 +43,7 @@ GitHub Action for minifying JavaScript, CSS, and HTML files with detailed report ```yaml - name: Minify with Quality Gates - uses: srod/node-minify/packages/action@main + uses: srod/node-minify@v1 with: input: "src/app.js" output: "dist/app.min.js" diff --git a/packages/action/__tests__/action.test.ts b/packages/action/__tests__/action.test.ts index 20c3c9c93..b407c6ea7 100644 --- a/packages/action/__tests__/action.test.ts +++ b/packages/action/__tests__/action.test.ts @@ -1,6 +1,7 @@ /*! node-minify action tests - MIT Licensed */ import { describe, expect, test } from "vitest"; +import { checkThresholds } from "../src/checks.ts"; import type { ActionInputs, FileResult, MinifyResult } from "../src/types.ts"; describe("Action Types", () => { @@ -72,25 +73,35 @@ describe("Action Types", () => { describe("Threshold Logic", () => { test("should detect size increase", () => { const reduction = -5; - const failOnIncrease = true; + const inputs = { + failOnIncrease: true, + minReduction: 0, + }; - const shouldFail = failOnIncrease && reduction < 0; - expect(shouldFail).toBe(true); + const error = checkThresholds(reduction, inputs); + expect(error).toContain("larger than original"); + expect(error).toContain("5.0% increase"); }); test("should detect insufficient reduction", () => { const reduction = 30; - const minReduction = 50; + const inputs = { + failOnIncrease: false, + minReduction: 50, + }; - const shouldFail = minReduction > 0 && reduction < minReduction; - expect(shouldFail).toBe(true); + const error = checkThresholds(reduction, inputs); + expect(error).toContain("below minimum threshold"); }); test("should pass when reduction meets threshold", () => { const reduction = 60; - const minReduction = 50; + const inputs = { + failOnIncrease: false, + minReduction: 50, + }; - const shouldFail = minReduction > 0 && reduction < minReduction; - expect(shouldFail).toBe(false); + const error = checkThresholds(reduction, inputs); + expect(error).toBeNull(); }); }); diff --git a/packages/action/src/checks.ts b/packages/action/src/checks.ts new file mode 100644 index 000000000..a9136b4d4 --- /dev/null +++ b/packages/action/src/checks.ts @@ -0,0 +1,22 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import type { ActionInputs } from "./types.ts"; + +export function checkThresholds( + reduction: number, + inputs: Pick +): string | null { + if (inputs.failOnIncrease && reduction < 0) { + return `Minified size is larger than original (${Math.abs(reduction).toFixed(1)}% increase)`; + } + + if (inputs.minReduction > 0 && reduction < inputs.minReduction) { + return `Reduction ${reduction.toFixed(1)}% is below minimum threshold ${inputs.minReduction}%`; + } + + return null; +} diff --git a/packages/action/src/index.ts b/packages/action/src/index.ts index 8dc4632bc..0ef799678 100644 --- a/packages/action/src/index.ts +++ b/packages/action/src/index.ts @@ -6,6 +6,7 @@ import { info, setFailed } from "@actions/core"; import { context } from "@actions/github"; +import { checkThresholds } from "./checks.ts"; import { parseInputs, validateJavaCompressor } from "./inputs.ts"; import { runMinification } from "./minify.ts"; import { setMinifyOutputs } from "./outputs.ts"; @@ -37,20 +38,9 @@ async function run(): Promise { addAnnotations(result); } - if (inputs.failOnIncrease && result.totalReduction < 0) { - setFailed( - `Minified size is larger than original (${result.totalReduction.toFixed(1)}% increase)` - ); - return; - } - - if ( - inputs.minReduction > 0 && - result.totalReduction < inputs.minReduction - ) { - setFailed( - `Reduction ${result.totalReduction.toFixed(1)}% is below minimum threshold ${inputs.minReduction}%` - ); + const thresholdError = checkThresholds(result.totalReduction, inputs); + if (thresholdError) { + setFailed(thresholdError); return; } diff --git a/packages/action/src/reporters/annotations.ts b/packages/action/src/reporters/annotations.ts index ab5aca8c1..953409643 100644 --- a/packages/action/src/reporters/annotations.ts +++ b/packages/action/src/reporters/annotations.ts @@ -12,7 +12,13 @@ const VERY_LOW_REDUCTION_THRESHOLD = 5; export function addAnnotations(result: MinifyResult): void { for (const file of result.files) { - if (file.reduction < VERY_LOW_REDUCTION_THRESHOLD) { + if (file.reduction < 0) { + error( + `Minified file is larger than original (${Math.abs(file.reduction).toFixed(1)}% increase). ` + + `This may indicate an issue with the compressor settings.`, + { file: file.file } + ); + } else if (file.reduction < VERY_LOW_REDUCTION_THRESHOLD) { warning( `Very low compression ratio (${file.reduction.toFixed(1)}%). ` + `Consider reviewing for dead code or checking if file is already minified.`, @@ -25,14 +31,6 @@ export function addAnnotations(result: MinifyResult): void { { file: file.file } ); } - - if (file.reduction < 0) { - error( - `Minified file is larger than original (${file.reduction.toFixed(1)}% increase). ` + - `This may indicate an issue with the compressor settings.`, - { file: file.file } - ); - } } } diff --git a/packages/action/src/reporters/comment.ts b/packages/action/src/reporters/comment.ts index fc7149640..2aeae7465 100644 --- a/packages/action/src/reporters/comment.ts +++ b/packages/action/src/reporters/comment.ts @@ -31,7 +31,7 @@ export async function postPRComment( const body = generateCommentBody(result); - const { data: comments } = await octokit.rest.issues.listComments({ + const comments = await octokit.paginate(octokit.rest.issues.listComments, { owner, repo, issue_number: prNumber, diff --git a/packages/action/src/reporters/summary.ts b/packages/action/src/reporters/summary.ts index 7c9b28a4c..a92b829dd 100644 --- a/packages/action/src/reporters/summary.ts +++ b/packages/action/src/reporters/summary.ts @@ -64,10 +64,10 @@ export async function generateBenchmarkSummary( return [ { data: `${c.compressor}${badge}` }, - { data: c.size ? prettyBytes(c.size) : "-" }, - { data: c.reduction ? `${c.reduction.toFixed(1)}%` : "-" }, - { data: c.gzipSize ? prettyBytes(c.gzipSize) : "-" }, - { data: c.timeMs ? `${c.timeMs}ms` : "-" }, + { data: c.size != null ? prettyBytes(c.size) : "-" }, + { data: c.reduction != null ? `${c.reduction.toFixed(1)}%` : "-" }, + { data: c.gzipSize != null ? prettyBytes(c.gzipSize) : "-" }, + { data: c.timeMs != null ? `${c.timeMs}ms` : "-" }, ]; }); diff --git a/scripts/check-published.ts b/scripts/check-published.ts index 92c4e3c37..58af1b8d2 100644 --- a/scripts/check-published.ts +++ b/scripts/check-published.ts @@ -78,4 +78,7 @@ async function main() { } } -main(); +main().catch((err) => { + console.error("Error checking packages:", err); + process.exit(1); +}); From 4910a33bdc219753f1f5aba40d8a6af9929ad669 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 23:10:01 +0100 Subject: [PATCH 15/49] test(utils): add coverage for getFilesizeGzippedRaw --- packages/utils/__tests__/utils.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/utils/__tests__/utils.test.ts b/packages/utils/__tests__/utils.test.ts index 22889dd67..0d431e2ed 100644 --- a/packages/utils/__tests__/utils.test.ts +++ b/packages/utils/__tests__/utils.test.ts @@ -25,6 +25,7 @@ import { getContentFromFiles, getFilesizeBrotliInBytes, getFilesizeGzippedInBytes, + getFilesizeGzippedRaw, getFilesizeInBytes, isValidFile, prettyBytes, @@ -514,6 +515,18 @@ describe("Package: utils", () => { }); }); + describe("getFilesizeGzippedRaw", () => { + test("should return file size in bytes", async () => { + const size = await getFilesizeGzippedRaw(fixtureFile); + expect(typeof size).toBe("number"); + expect(size).toBeGreaterThan(0); + }); + + test("should throw if file does not exist", async () => { + await expect(getFilesizeGzippedRaw("fake.js")).rejects.toThrow(); + }); + }); + describe("getFilesizeBrotliInBytes", () => { test("should return file size", async () => { const size = await getFilesizeBrotliInBytes(fixtureFile); From 3ee69f593d4707c886543a1af89bc9e8b679dbaf Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 23:17:07 +0100 Subject: [PATCH 16/49] test(utils): improve error assertions and add directory check for getFilesizeGzippedRaw chore(scripts): improve error handling in check-published --- packages/utils/__tests__/utils.test.ts | 12 ++++++++++-- scripts/check-published.ts | 7 ++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/utils/__tests__/utils.test.ts b/packages/utils/__tests__/utils.test.ts index 0d431e2ed..371991aed 100644 --- a/packages/utils/__tests__/utils.test.ts +++ b/packages/utils/__tests__/utils.test.ts @@ -522,8 +522,16 @@ describe("Package: utils", () => { expect(size).toBeGreaterThan(0); }); - test("should throw if file does not exist", async () => { - await expect(getFilesizeGzippedRaw("fake.js")).rejects.toThrow(); + test("should throw FileOperationError if file does not exist", async () => { + await expect(getFilesizeGzippedRaw("fake.js")).rejects.toThrow( + FileOperationError + ); + }); + + test("should throw FileOperationError if path is a directory", async () => { + await expect(getFilesizeGzippedRaw(__dirname)).rejects.toThrow( + FileOperationError + ); }); }); diff --git a/scripts/check-published.ts b/scripts/check-published.ts index 58af1b8d2..9493f7d6d 100644 --- a/scripts/check-published.ts +++ b/scripts/check-published.ts @@ -79,6 +79,11 @@ async function main() { } main().catch((err) => { - console.error("Error checking packages:", err); + if (err instanceof Error) { + console.error("Error checking packages:", err.message); + console.error(err.stack); + } else { + console.error("An unknown error occurred:", String(err)); + } process.exit(1); }); From 757bbcbd574d925f894844de4b39f38ed8040125 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Fri, 9 Jan 2026 22:14:16 +0000 Subject: [PATCH 17/49] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`f?= =?UTF-8?q?eature/github-action-package`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @srod. * https://github.com/srod/node-minify/pull/2760#issuecomment-3730682095 The following files were modified: * `packages/action/src/checks.ts` * `packages/action/src/index.ts` * `packages/action/src/inputs.ts` * `packages/action/src/minify.ts` * `packages/action/src/outputs.ts` * `packages/action/src/reporters/annotations.ts` * `packages/action/src/reporters/comment.ts` * `packages/action/src/reporters/summary.ts` * `packages/benchmark/src/runner.ts` * `packages/utils/src/getFilesizeGzippedInBytes.ts` * `scripts/check-published.ts` --- packages/action/src/checks.ts | 11 +++++++- packages/action/src/index.ts | 9 ++++++- packages/action/src/inputs.ts | 27 ++++++++++++++++++- packages/action/src/minify.ts | 23 +++++++++++++++- packages/action/src/outputs.ts | 14 +++++++++- packages/action/src/reporters/annotations.ts | 18 ++++++++++++- packages/action/src/reporters/comment.ts | 14 +++++++++- packages/action/src/reporters/summary.ts | 17 +++++++++++- packages/benchmark/src/runner.ts | 9 ++++++- .../utils/src/getFilesizeGzippedInBytes.ts | 19 +++++++------ scripts/check-published.ts | 23 +++++++++++++++- 11 files changed, 164 insertions(+), 20 deletions(-) diff --git a/packages/action/src/checks.ts b/packages/action/src/checks.ts index a9136b4d4..bc6872e37 100644 --- a/packages/action/src/checks.ts +++ b/packages/action/src/checks.ts @@ -6,6 +6,15 @@ import type { ActionInputs } from "./types.ts"; +/** + * Check whether a percent reduction violates configured thresholds. + * + * @param reduction - Percent change in size after minification (positive means size decreased; negative means size increased) + * @param inputs - Configuration with: + * - `failOnIncrease`: if true, treat any increase (negative `reduction`) as a violation + * - `minReduction`: minimum allowed reduction percentage; values below this are violations when > 0 + * @returns A human-readable error message describing the threshold violation, or `null` if no violation + */ export function checkThresholds( reduction: number, inputs: Pick @@ -19,4 +28,4 @@ export function checkThresholds( } return null; -} +} \ No newline at end of file diff --git a/packages/action/src/index.ts b/packages/action/src/index.ts index 0ef799678..d8ef90a81 100644 --- a/packages/action/src/index.ts +++ b/packages/action/src/index.ts @@ -14,6 +14,13 @@ import { addAnnotations } from "./reporters/annotations.ts"; import { postPRComment } from "./reporters/comment.ts"; import { generateSummary } from "./reporters/summary.ts"; +/** + * Orchestrates the minification workflow for the GitHub Action. + * + * Parses and validates inputs, runs the minification, and persists outputs. + * Optionally generates a summary, posts a pull-request comment when running in a PR, and adds annotations based on inputs. + * If configured thresholds are violated or an error is thrown, signals action failure with an explanatory message. + */ async function run(): Promise { try { const inputs = parseInputs(); @@ -56,4 +63,4 @@ async function run(): Promise { } } -run(); +run(); \ No newline at end of file diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts index ace53de07..1a2d93c1e 100644 --- a/packages/action/src/inputs.ts +++ b/packages/action/src/inputs.ts @@ -20,6 +20,22 @@ const DEPRECATED_COMPRESSORS: Record = { sqwish: "sqwish is no longer maintained. Use 'lightningcss' or 'clean-css' instead.", }; +/** + * Parse and validate GitHub Action inputs into an ActionInputs object. + * + * Reads inputs such as `input`, `output`, `compressor`, `type`, `options`, + * reporting flags, benchmarking settings, and other flags, applying defaults + * and validations (including JSON parsing for `options` and required `type` + * for certain compressors). + * + * @returns An object containing the parsed action inputs, including `input`, + * `output`, `compressor`, `type`, `options`, report flags, benchmark settings, + * `minReduction`, `includeGzip`, `workingDirectory`, and `githubToken`. + * + * @throws Error if a compressor that requires a `type` is selected but `type` + * is not provided. + * @throws Error if the `options` input is present but is not valid JSON. + */ export function parseInputs(): ActionInputs { const compressor = getInput("compressor") || "terser"; const type = getInput("type") as "js" | "css" | undefined; @@ -64,6 +80,15 @@ export function parseInputs(): ActionInputs { }; } +/** + * Validates a compressor identifier and emits warnings for deprecated or non-built-in compressors. + * + * Emits a warning when the compressor is listed as deprecated and emits a separate warning + * when the compressor is not recognized as a built-in compressor (indicating it will be + * treated as a custom npm package or local file). + * + * @param compressor - The compressor name or identifier to validate (e.g., "terser", "esbuild", or a custom package) + */ export function validateCompressor(compressor: string): void { const deprecationMessage = DEPRECATED_COMPRESSORS[compressor]; if (deprecationMessage) { @@ -80,4 +105,4 @@ export function validateCompressor(compressor: string): void { export const validateJavaCompressor = validateCompressor; -export { DEPRECATED_COMPRESSORS, JAVA_COMPRESSORS, TYPE_REQUIRED_COMPRESSORS }; +export { DEPRECATED_COMPRESSORS, JAVA_COMPRESSORS, TYPE_REQUIRED_COMPRESSORS }; \ No newline at end of file diff --git a/packages/action/src/minify.ts b/packages/action/src/minify.ts index f3fa493e8..fc634181b 100644 --- a/packages/action/src/minify.ts +++ b/packages/action/src/minify.ts @@ -10,11 +10,32 @@ import { minify } from "@node-minify/core"; import { getFilesizeGzippedRaw, resolveCompressor } from "@node-minify/utils"; import type { ActionInputs, FileResult, MinifyResult } from "./types.ts"; +/** + * Get the size of a file in bytes. + * + * @param filePath - Path to the file + * @returns The file size in bytes + */ async function getFileSize(filePath: string): Promise { const stats = await stat(filePath); return stats.size; } +/** + * Minifies a single input file according to the provided action inputs and returns summary metrics. + * + * Uses `inputs` to resolve input/output paths, run the selected compressor with optional type and options, + * and optionally computes gzipped size. The result includes per-file metrics and aggregated totals. + * + * @param inputs - Configuration for the minification run (input/output paths relative to `workingDirectory`, `compressor` selection, optional `type` and `options`, and `includeGzip` flag) + * @returns A `MinifyResult` containing: + * - `files`: an array with one `FileResult` (`file`, `originalSize`, `minifiedSize`, `reduction`, optional `gzipSize`, `timeMs`) + * - `compressor`: the human-readable compressor label + * - `totalOriginalSize`: original file size in bytes + * - `totalMinifiedSize`: minified file size in bytes + * - `totalReduction`: percentage reduction (0–100) + * - `totalTimeMs`: elapsed minification time in milliseconds + */ export async function runMinification( inputs: ActionInputs ): Promise { @@ -67,4 +88,4 @@ export async function runMinification( totalReduction: reduction, totalTimeMs: timeMs, }; -} +} \ No newline at end of file diff --git a/packages/action/src/outputs.ts b/packages/action/src/outputs.ts index 8880dba9a..51aa9a28d 100644 --- a/packages/action/src/outputs.ts +++ b/packages/action/src/outputs.ts @@ -7,6 +7,13 @@ import { setOutput } from "@actions/core"; import type { BenchmarkResult, MinifyResult } from "./types.ts"; +/** + * Publishes minification metrics from a MinifyResult to GitHub Actions outputs. + * + * Sets the following outputs: `original-size`, `minified-size`, `reduction-percent` (formatted to two decimal places), `time-ms`, and `report-json`. If per-file gzip sizes are present, also sets `gzip-size` to the total gzip size across files. + * + * @param result - Minification summary containing total and per-file metrics used to populate action outputs + */ export function setMinifyOutputs(result: MinifyResult): void { setOutput("original-size", result.totalOriginalSize); setOutput("minified-size", result.totalMinifiedSize); @@ -23,6 +30,11 @@ export function setMinifyOutputs(result: MinifyResult): void { } } +/** + * Exposes benchmark metrics as GitHub Actions outputs. + * + * @param result - The benchmark result object whose properties are published as Action outputs. When present, `recommended` is written to `benchmark-winner`, `bestCompression` to `best-compression`, and `bestSpeed` to `best-speed`. The entire `result` is written as JSON to `benchmark-json`. + */ export function setBenchmarkOutputs(result: BenchmarkResult): void { if (result.recommended) { setOutput("benchmark-winner", result.recommended); @@ -34,4 +46,4 @@ export function setBenchmarkOutputs(result: BenchmarkResult): void { setOutput("best-speed", result.bestSpeed); } setOutput("benchmark-json", JSON.stringify(result)); -} +} \ No newline at end of file diff --git a/packages/action/src/reporters/annotations.ts b/packages/action/src/reporters/annotations.ts index 953409643..b8de9822c 100644 --- a/packages/action/src/reporters/annotations.ts +++ b/packages/action/src/reporters/annotations.ts @@ -10,6 +10,16 @@ import type { MinifyResult } from "../types.ts"; const LOW_REDUCTION_THRESHOLD = 20; const VERY_LOW_REDUCTION_THRESHOLD = 5; +/** + * Emit GitHub Actions annotations for each minified file based on its size reduction. + * + * For each file in `result.files` this reports an annotation scoped to that file: + * - An error if the minified file is larger than the original. + * - A warning if the reduction is very small, suggesting review for dead code or prior minification. + * - A notice if the reduction is low, suggesting the file may already be optimized. + * + * @param result - Minification result containing an array of file reports with reduction percentages and file paths + */ export function addAnnotations(result: MinifyResult): void { for (const file of result.files) { if (file.reduction < 0) { @@ -34,6 +44,12 @@ export function addAnnotations(result: MinifyResult): void { } } +/** + * Record an error annotation for a specific file indicating that minification failed. + * + * @param file - The file path to associate with the annotation + * @param errorMsg - A human-readable message describing the minification error + */ export function addErrorAnnotation(file: string, errorMsg: string): void { error(`Minification failed: ${errorMsg}`, { file }); -} +} \ No newline at end of file diff --git a/packages/action/src/reporters/comment.ts b/packages/action/src/reporters/comment.ts index 2aeae7465..5a4b8315d 100644 --- a/packages/action/src/reporters/comment.ts +++ b/packages/action/src/reporters/comment.ts @@ -11,6 +11,12 @@ import type { MinifyResult } from "../types.ts"; const COMMENT_TAG = ""; +/** + * Posts or updates a pull request comment with a minification report. + * + * @param result - Minification results containing per-file metrics, totals, compressor name, and execution time + * @param githubToken - GitHub API token used to authenticate requests; when `undefined`, the function skips posting + */ export async function postPRComment( result: MinifyResult, githubToken: string | undefined @@ -58,6 +64,12 @@ export async function postPRComment( } } +/** + * Builds the Markdown body for the node-minify PR comment, including a per-file table, totals, and a configuration section. + * + * @param result - Minification results used to populate the report (expects `files`, `totalOriginalSize`, `totalMinifiedSize`, `totalReduction`, `compressor`, and `totalTimeMs`) + * @returns The Markdown string for the PR comment, beginning with `COMMENT_TAG` and containing the file table, total summary, and configuration details + */ function generateCommentBody(result: MinifyResult): string { const filesTable = result.files .map( @@ -86,4 +98,4 @@ ${filesTable} --- *Generated by [node-minify](https://github.com/srod/node-minify) action* `; -} +} \ No newline at end of file diff --git a/packages/action/src/reporters/summary.ts b/packages/action/src/reporters/summary.ts index a92b829dd..5628d2c8c 100644 --- a/packages/action/src/reporters/summary.ts +++ b/packages/action/src/reporters/summary.ts @@ -8,6 +8,14 @@ import { summary } from "@actions/core"; import { prettyBytes } from "@node-minify/utils"; import type { BenchmarkResult, MinifyResult } from "../types.ts"; +/** + * Generate a GitHub Actions summary reporting per-file minification metrics and totals. + * + * Builds a Markdown table with columns File, Original, Minified, Reduction, Gzip, and Time, + * includes the compressor name, and appends a total line showing aggregated original/minified sizes and overall reduction. + * + * @param result - Minification results containing per-file metrics and aggregate totals used to populate the summary + */ export async function generateSummary(result: MinifyResult): Promise { const rows = result.files.map((f) => [ { data: `\`${f.file}\`` }, @@ -40,6 +48,13 @@ export async function generateSummary(result: MinifyResult): Promise { .write(); } +/** + * Generate a Markdown benchmark summary comparing compressors and write it to the GitHub Actions summary. + * + * Builds a table of compressors showing status, size, reduction, gzip size, and time; marks recommended, best-speed, and best-compression entries with badges; and includes the source file and recommended compressor in the summary. + * + * @param result - BenchmarkResult containing the file path, originalSize, a list of compressors with their metrics or errors, and optional recommended/best markers used to annotate the table + */ export async function generateBenchmarkSummary( result: BenchmarkResult ): Promise { @@ -90,4 +105,4 @@ export async function generateBenchmarkSummary( .addBreak() .addRaw(`**Recommended:** ${result.recommended || "N/A"}`) .write(); -} +} \ No newline at end of file diff --git a/packages/benchmark/src/runner.ts b/packages/benchmark/src/runner.ts index 17650a696..658cd6140 100644 --- a/packages/benchmark/src/runner.ts +++ b/packages/benchmark/src/runner.ts @@ -53,6 +53,13 @@ export async function runBenchmark( }; } +/** + * Benchmarks a single input file using the configured compressors and returns per-compressor metrics. + * + * @param file - Path to the input file to benchmark + * @param options - Benchmark configuration (compressors to run, iterations, callbacks, and metric options) + * @returns A FileResult containing the file path, original size in bytes and human-readable form, and an array of CompressorMetrics for each attempted compressor + */ async function benchmarkFile( file: string, options: BenchmarkOptions @@ -228,4 +235,4 @@ function cleanupTempFiles(files: string[]): void { unlinkSync(file); } catch {} } -} +} \ No newline at end of file diff --git a/packages/utils/src/getFilesizeGzippedInBytes.ts b/packages/utils/src/getFilesizeGzippedInBytes.ts index f513bdcfa..34f92daf0 100644 --- a/packages/utils/src/getFilesizeGzippedInBytes.ts +++ b/packages/utils/src/getFilesizeGzippedInBytes.ts @@ -10,10 +10,11 @@ import { isValidFile } from "./isValidFile.ts"; import { prettyBytes } from "./prettyBytes.ts"; /** - * Internal helper to calculate gzipped size of a file using streaming. - * @param file - Path to the file - * @returns Gzipped size in bytes - * @throws {FileOperationError} If file doesn't exist or is not a valid file + * Compute the gzipped size of a file in bytes. + * + * @param file - Path to the file to measure + * @returns The gzipped size in bytes + * @throws FileOperationError if the file does not exist or the path is not a valid file * @internal */ async function getGzipSize(file: string): Promise { @@ -45,12 +46,10 @@ async function getGzipSize(file: string): Promise { } /** - * Get the gzipped file size as a human-readable string. + * Get the gzipped size of a file as a human-readable string. + * * @param file - Path to the file - * @returns Formatted gzipped file size string (e.g., "1.5 kB") - * @example - * const size = await getFilesizeGzippedInBytes('bundle.js') - * console.log(size) // '12.3 kB' + * @returns The gzipped size formatted for display (for example, "1.5 kB") */ export async function getFilesizeGzippedInBytes(file: string): Promise { try { @@ -83,4 +82,4 @@ export async function getFilesizeGzippedRaw(file: string): Promise { error as Error ); } -} +} \ No newline at end of file diff --git a/scripts/check-published.ts b/scripts/check-published.ts index 9493f7d6d..06e518515 100644 --- a/scripts/check-published.ts +++ b/scripts/check-published.ts @@ -4,6 +4,11 @@ import { join } from "node:path"; const PACKAGES_DIR = "packages"; +/** + * Lists package subdirectories under PACKAGES_DIR that contain a package.json file. + * + * @returns An array of directory names for packages that have a package.json in their folder + */ function getPackageDirs() { return readdirSync(PACKAGES_DIR, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) @@ -13,6 +18,13 @@ function getPackageDirs() { .map((entry) => entry.name); } +/** + * Checks whether an npm package exists on the registry and whether a specific version is published. + * + * @param packageName - The npm package name to check (e.g., "lodash") + * @param version - The package version to check for publication (e.g., "1.2.3") + * @returns An object with `exists`: `true` if the package is found on the npm registry, `false` otherwise; and `publishedVersion`: `true` if the specified `version` is published, `false` otherwise. + */ async function checkPublished(packageName: string, version: string) { try { const latest = execSync(`npm view ${packageName} version --json`, { @@ -38,6 +50,15 @@ async function checkPublished(packageName: string, version: string) { } } +/** + * Checks all non-private packages under the packages directory and reports their publication status on npm. + * + * Scans each package's package.json, queries npm to determine whether the package exists and whether the current + * package version is published, and prints categorized results to the console: + * - packages not found on npm (new packages) + * - packages with unpublished versions (updates needed) + * - or a confirmation that all packages are up to date + */ async function main() { const dirs = getPackageDirs(); const results = []; @@ -86,4 +107,4 @@ main().catch((err) => { console.error("An unknown error occurred:", String(err)); } process.exit(1); -}); +}); \ No newline at end of file From 5b1bee11e28ba2c6c2c10f73a6a91e29c39ced21 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 23:26:13 +0100 Subject: [PATCH 18/49] fix(security): sanitize inputs in release workflow and check-published script --- .github/workflows/release-action.yml | 19 ++++++++++-- scripts/check-published.ts | 44 ++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index c2bd401b9..2eace4987 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -21,7 +21,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - ref: ${{ github.event.inputs.tag || github.ref }} + ref: ${{ github.event.inputs.tag || (github.event.release.tag_name || github.ref) }} - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -51,8 +51,23 @@ jobs: else VERSION="$REF_NAME" fi + + # Validate that VERSION looks like a tag (starts with v followed by numbers) + if ! [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then + echo "Error: Version '$VERSION' is not a valid release tag (must start with 'v' e.g. v1.0.0)" + exit 1 + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "major=$(echo $VERSION | cut -d. -f1)" >> $GITHUB_OUTPUT + + # Extract major version using regex + if [[ "$VERSION" =~ ^v([0-9]+) ]]; then + MAJOR="v${BASH_REMATCH[1]}" + echo "major=$MAJOR" >> $GITHUB_OUTPUT + else + echo "Error: Could not extract major version from '$VERSION'" + exit 1 + fi - name: Update major version tag run: | diff --git a/scripts/check-published.ts b/scripts/check-published.ts index 06e518515..4f803f465 100644 --- a/scripts/check-published.ts +++ b/scripts/check-published.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; @@ -25,18 +25,28 @@ function getPackageDirs() { * @param version - The package version to check for publication (e.g., "1.2.3") * @returns An object with `exists`: `true` if the package is found on the npm registry, `false` otherwise; and `publishedVersion`: `true` if the specified `version` is published, `false` otherwise. */ -async function checkPublished(packageName: string, version: string) { +function checkPublished(packageName: string, version: string) { + // Basic validation to prevent command injection + if (!/^[\w@/-]+$/.test(packageName) || !/^[\w.-]+$/.test(version)) { + return { exists: false, publishedVersion: false }; + } + try { - const latest = execSync(`npm view ${packageName} version --json`, { - stdio: "pipe", - }) + const latest = execFileSync( + "npm", + ["view", packageName, "version", "--json"], + { + stdio: "pipe", + } + ) .toString() .trim(); if (latest === "" || latest === "undefined") return { exists: false, publishedVersion: false }; - const specific = execSync( - `npm view ${packageName}@${version} version --json`, + const specific = execFileSync( + "npm", + ["view", `${packageName}@${version}`, "version", "--json"], { stdio: "pipe" } ) .toString() @@ -66,13 +76,21 @@ async function main() { console.log("Checking packages..."); for (const dir of dirs) { - const pkgPath = join(PACKAGES_DIR, dir, "package.json"); - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + try { + const pkgPath = join(PACKAGES_DIR, dir, "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - if (pkg.private) continue; + if (pkg.private) continue; - const status = await checkPublished(pkg.name, pkg.version); - results.push({ name: pkg.name, version: pkg.version, ...status }); + const status = checkPublished(pkg.name, pkg.version); + results.push({ name: pkg.name, version: pkg.version, ...status }); + } catch (error) { + console.warn( + `Skipping package ${dir}:`, + error instanceof Error ? error.message : String(error) + ); + continue; + } } const missing = results.filter((r) => !r.exists); @@ -107,4 +125,4 @@ main().catch((err) => { console.error("An unknown error occurred:", String(err)); } process.exit(1); -}); \ No newline at end of file +}); From f954034bd9947c2d29b6192d2a349d4b9e1d5562 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 23:31:44 +0100 Subject: [PATCH 19/49] fix(scripts): allow dots in package names in check-published regex --- packages/action/src/checks.ts | 2 +- packages/action/src/index.ts | 2 +- packages/action/src/inputs.ts | 2 +- packages/action/src/minify.ts | 2 +- packages/action/src/outputs.ts | 2 +- packages/action/src/reporters/annotations.ts | 2 +- packages/action/src/reporters/comment.ts | 2 +- packages/action/src/reporters/summary.ts | 2 +- packages/benchmark/src/runner.ts | 2 +- packages/utils/src/getFilesizeGzippedInBytes.ts | 2 +- scripts/check-published.ts | 3 +-- 11 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/action/src/checks.ts b/packages/action/src/checks.ts index bc6872e37..952c879a9 100644 --- a/packages/action/src/checks.ts +++ b/packages/action/src/checks.ts @@ -28,4 +28,4 @@ export function checkThresholds( } return null; -} \ No newline at end of file +} diff --git a/packages/action/src/index.ts b/packages/action/src/index.ts index d8ef90a81..42880ad7b 100644 --- a/packages/action/src/index.ts +++ b/packages/action/src/index.ts @@ -63,4 +63,4 @@ async function run(): Promise { } } -run(); \ No newline at end of file +run(); diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts index 1a2d93c1e..5af20a454 100644 --- a/packages/action/src/inputs.ts +++ b/packages/action/src/inputs.ts @@ -105,4 +105,4 @@ export function validateCompressor(compressor: string): void { export const validateJavaCompressor = validateCompressor; -export { DEPRECATED_COMPRESSORS, JAVA_COMPRESSORS, TYPE_REQUIRED_COMPRESSORS }; \ No newline at end of file +export { DEPRECATED_COMPRESSORS, JAVA_COMPRESSORS, TYPE_REQUIRED_COMPRESSORS }; diff --git a/packages/action/src/minify.ts b/packages/action/src/minify.ts index fc634181b..972ed09fd 100644 --- a/packages/action/src/minify.ts +++ b/packages/action/src/minify.ts @@ -88,4 +88,4 @@ export async function runMinification( totalReduction: reduction, totalTimeMs: timeMs, }; -} \ No newline at end of file +} diff --git a/packages/action/src/outputs.ts b/packages/action/src/outputs.ts index 51aa9a28d..73742779f 100644 --- a/packages/action/src/outputs.ts +++ b/packages/action/src/outputs.ts @@ -46,4 +46,4 @@ export function setBenchmarkOutputs(result: BenchmarkResult): void { setOutput("best-speed", result.bestSpeed); } setOutput("benchmark-json", JSON.stringify(result)); -} \ No newline at end of file +} diff --git a/packages/action/src/reporters/annotations.ts b/packages/action/src/reporters/annotations.ts index b8de9822c..738783b10 100644 --- a/packages/action/src/reporters/annotations.ts +++ b/packages/action/src/reporters/annotations.ts @@ -52,4 +52,4 @@ export function addAnnotations(result: MinifyResult): void { */ export function addErrorAnnotation(file: string, errorMsg: string): void { error(`Minification failed: ${errorMsg}`, { file }); -} \ No newline at end of file +} diff --git a/packages/action/src/reporters/comment.ts b/packages/action/src/reporters/comment.ts index 5a4b8315d..576419701 100644 --- a/packages/action/src/reporters/comment.ts +++ b/packages/action/src/reporters/comment.ts @@ -98,4 +98,4 @@ ${filesTable} --- *Generated by [node-minify](https://github.com/srod/node-minify) action* `; -} \ No newline at end of file +} diff --git a/packages/action/src/reporters/summary.ts b/packages/action/src/reporters/summary.ts index 5628d2c8c..114591e38 100644 --- a/packages/action/src/reporters/summary.ts +++ b/packages/action/src/reporters/summary.ts @@ -105,4 +105,4 @@ export async function generateBenchmarkSummary( .addBreak() .addRaw(`**Recommended:** ${result.recommended || "N/A"}`) .write(); -} \ No newline at end of file +} diff --git a/packages/benchmark/src/runner.ts b/packages/benchmark/src/runner.ts index 658cd6140..1dbda92df 100644 --- a/packages/benchmark/src/runner.ts +++ b/packages/benchmark/src/runner.ts @@ -235,4 +235,4 @@ function cleanupTempFiles(files: string[]): void { unlinkSync(file); } catch {} } -} \ No newline at end of file +} diff --git a/packages/utils/src/getFilesizeGzippedInBytes.ts b/packages/utils/src/getFilesizeGzippedInBytes.ts index 34f92daf0..6ead38b5a 100644 --- a/packages/utils/src/getFilesizeGzippedInBytes.ts +++ b/packages/utils/src/getFilesizeGzippedInBytes.ts @@ -82,4 +82,4 @@ export async function getFilesizeGzippedRaw(file: string): Promise { error as Error ); } -} \ No newline at end of file +} diff --git a/scripts/check-published.ts b/scripts/check-published.ts index 4f803f465..96919616d 100644 --- a/scripts/check-published.ts +++ b/scripts/check-published.ts @@ -27,7 +27,7 @@ function getPackageDirs() { */ function checkPublished(packageName: string, version: string) { // Basic validation to prevent command injection - if (!/^[\w@/-]+$/.test(packageName) || !/^[\w.-]+$/.test(version)) { + if (!/^[\w@/.-]+$/.test(packageName) || !/^[\w.-]+$/.test(version)) { return { exists: false, publishedVersion: false }; } @@ -89,7 +89,6 @@ async function main() { `Skipping package ${dir}:`, error instanceof Error ? error.message : String(error) ); - continue; } } From cd46f1b8a6be9a64ba8d583790d9ad73590cb4f2 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Fri, 9 Jan 2026 23:54:32 +0100 Subject: [PATCH 20/49] ci(release): commit compiled action dist during release --- .github/workflows/release-action.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index 2eace4987..63534d4ad 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -69,11 +69,17 @@ jobs: exit 1 fi - - name: Update major version tag + - name: Commit built action dist run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + # Force add dist (ignored by .gitignore) + git add -f packages/action/dist/ + git commit -m "chore: build action dist for ${{ steps.version.outputs.version }}" || echo "No changes to commit" + + - name: Update major version tag + run: | # Force update the major version tag (e.g., v1) MAJOR="${{ steps.version.outputs.major }}" git tag -fa "$MAJOR" -m "Update $MAJOR tag to ${{ steps.version.outputs.version }}" From ce947a56d51a00642f170b3af71d7bf9e9ee8420 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 00:00:49 +0100 Subject: [PATCH 21/49] feat(action): add benchmark support to GitHub Action --- packages/action/src/benchmark.ts | 72 ++++++++++++++++++++++++++++++++ packages/action/src/index.ts | 24 ++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 packages/action/src/benchmark.ts diff --git a/packages/action/src/benchmark.ts b/packages/action/src/benchmark.ts new file mode 100644 index 000000000..48667e33a --- /dev/null +++ b/packages/action/src/benchmark.ts @@ -0,0 +1,72 @@ +/*! + * node-minify + * Copyright (c) 2011-2026 Rodolphe Stoclin + * MIT Licensed + */ + +import { resolve } from "node:path"; +import { benchmark } from "@node-minify/benchmark"; +import type { ActionInputs, BenchmarkResult } from "./types.ts"; + +/** + * Run benchmark comparison across multiple compressors. + * + * Uses the @node-minify/benchmark package to run each compressor and + * converts the results to the action's BenchmarkResult format. + * + * @param inputs - Action inputs containing input file, benchmark compressors, and options + * @returns BenchmarkResult with per-compressor metrics and recommendations + */ +export async function runBenchmark( + inputs: ActionInputs +): Promise { + const inputPath = resolve(inputs.workingDirectory, inputs.input); + + const result = await benchmark({ + input: inputPath, + compressors: inputs.benchmarkCompressors, + includeGzip: inputs.includeGzip, + type: inputs.type, + iterations: 1, + }); + + // Convert benchmark package result to action's BenchmarkResult type + // The benchmark package returns results per-file, we take the first file + const fileResult = result.files[0]; + if (!fileResult) { + return { + file: inputs.input, + originalSize: 0, + compressors: [], + recommended: undefined, + bestCompression: undefined, + bestSpeed: undefined, + }; + } + + return { + file: inputs.input, + originalSize: fileResult.originalSizeBytes, + compressors: fileResult.results.map((r) => ({ + compressor: r.compressor, + success: r.success, + size: r.sizeBytes, + reduction: r.reductionPercent, + gzipSize: typeof r.gzipSize === "string" ? undefined : r.gzipSize, + timeMs: r.timeMs, + error: r.error, + })), + recommended: + result.summary.recommended !== "N/A" + ? result.summary.recommended + : undefined, + bestCompression: + result.summary.bestCompression !== "N/A" + ? result.summary.bestCompression + : undefined, + bestSpeed: + result.summary.bestPerformance !== "N/A" + ? result.summary.bestPerformance + : undefined, + }; +} diff --git a/packages/action/src/index.ts b/packages/action/src/index.ts index 42880ad7b..58d72d4a6 100644 --- a/packages/action/src/index.ts +++ b/packages/action/src/index.ts @@ -6,13 +6,17 @@ import { info, setFailed } from "@actions/core"; import { context } from "@actions/github"; +import { runBenchmark } from "./benchmark.ts"; import { checkThresholds } from "./checks.ts"; import { parseInputs, validateJavaCompressor } from "./inputs.ts"; import { runMinification } from "./minify.ts"; -import { setMinifyOutputs } from "./outputs.ts"; +import { setBenchmarkOutputs, setMinifyOutputs } from "./outputs.ts"; import { addAnnotations } from "./reporters/annotations.ts"; import { postPRComment } from "./reporters/comment.ts"; -import { generateSummary } from "./reporters/summary.ts"; +import { + generateBenchmarkSummary, + generateSummary, +} from "./reporters/summary.ts"; /** * Orchestrates the minification workflow for the GitHub Action. @@ -45,6 +49,22 @@ async function run(): Promise { addAnnotations(result); } + if (inputs.benchmark) { + info( + `Running benchmark with compressors: ${inputs.benchmarkCompressors.join(", ")}...` + ); + const benchmarkResult = await runBenchmark(inputs); + setBenchmarkOutputs(benchmarkResult); + + if (inputs.reportSummary) { + await generateBenchmarkSummary(benchmarkResult); + } + + if (benchmarkResult.recommended) { + info(`🏆 Benchmark winner: ${benchmarkResult.recommended}`); + } + } + const thresholdError = checkThresholds(result.totalReduction, inputs); if (thresholdError) { setFailed(thresholdError); From 80af89c0c6d572145154bf11bef15b7d82f3b110 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 00:03:47 +0100 Subject: [PATCH 22/49] fix(action): add missing @node-minify/benchmark dependency --- bun.lock | 1 + packages/action/package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index 943fd8606..98d0c5e7c 100644 --- a/bun.lock +++ b/bun.lock @@ -74,6 +74,7 @@ "dependencies": { "@actions/core": "^1.11.1", "@actions/github": "^6.0.0", + "@node-minify/benchmark": "workspace:*", "@node-minify/core": "workspace:*", "@node-minify/utils": "workspace:*", }, diff --git a/packages/action/package.json b/packages/action/package.json index b19ca7544..5a1b6090f 100644 --- a/packages/action/package.json +++ b/packages/action/package.json @@ -52,6 +52,7 @@ "dependencies": { "@actions/core": "^1.11.1", "@actions/github": "^6.0.0", + "@node-minify/benchmark": "workspace:*", "@node-minify/core": "workspace:*", "@node-minify/utils": "workspace:*" }, From e481c45d9ca490cc3b4fd1233c9008f634c0f49b Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 00:05:57 +0100 Subject: [PATCH 23/49] fix(ci): build benchmark package before action in test workflow --- .github/workflows/test-action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index faefc3fbf..a79b148b7 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -33,6 +33,7 @@ jobs: run: | bun run build:deps bun run --filter '@node-minify/core' build + bun run --filter '@node-minify/benchmark' build - name: Build action run: bun run build From ada9968e379e46a6f9da94c0a685108ce461b441 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 00:16:38 +0100 Subject: [PATCH 24/49] ci(release): improve release-action workflow robustness and add concurrency --- .github/workflows/release-action.yml | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index 63534d4ad..0a6a92c58 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -10,6 +10,8 @@ on: required: true type: string +concurrency: release-${{ github.ref_name }} + permissions: contents: write @@ -51,15 +53,15 @@ jobs: else VERSION="$REF_NAME" fi - + # Validate that VERSION looks like a tag (starts with v followed by numbers) if ! [[ "$VERSION" =~ ^v[0-9]+(\.[0-9]+)*$ ]]; then echo "Error: Version '$VERSION' is not a valid release tag (must start with 'v' e.g. v1.0.0)" exit 1 fi - + echo "version=$VERSION" >> $GITHUB_OUTPUT - + # Extract major version using regex if [[ "$VERSION" =~ ^v([0-9]+) ]]; then MAJOR="v${BASH_REMATCH[1]}" @@ -73,17 +75,20 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - + # Force add dist (ignored by .gitignore) git add -f packages/action/dist/ - git commit -m "chore: build action dist for ${{ steps.version.outputs.version }}" || echo "No changes to commit" + git commit -m "chore: build action dist for ${{ steps.version.outputs.version }}" - name: Update major version tag run: | # Force update the major version tag (e.g., v1) MAJOR="${{ steps.version.outputs.major }}" git tag -fa "$MAJOR" -m "Update $MAJOR tag to ${{ steps.version.outputs.version }}" - git push origin "$MAJOR" --force + if ! git push origin "$MAJOR" --force; then + echo "Error: Failed to push major version tag $MAJOR" + exit 1 + fi - name: Create action release artifact run: | @@ -95,7 +100,7 @@ jobs: zip -r ../node-minify-action-${{ steps.version.outputs.version }}.zip . - name: Upload release artifact - if: github.event_name == 'release' + if: contains(fromJson('["release", "workflow_dispatch"]'), github.event_name) uses: softprops/action-gh-release@v2 with: files: node-minify-action-${{ steps.version.outputs.version }}.zip From 10b0760c0921c9513cdc8ba5143be7ba770d7c62 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 21:34:03 +0100 Subject: [PATCH 25/49] fix(action): add missing compressor dependencies and robust gzip size check --- bun.lock | 4 ++++ packages/action/package.json | 4 ++++ packages/action/src/outputs.ts | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index 98d0c5e7c..4f3ffd48f 100644 --- a/bun.lock +++ b/bun.lock @@ -76,6 +76,10 @@ "@actions/github": "^6.0.0", "@node-minify/benchmark": "workspace:*", "@node-minify/core": "workspace:*", + "@node-minify/esbuild": "workspace:*", + "@node-minify/oxc": "workspace:*", + "@node-minify/swc": "workspace:*", + "@node-minify/terser": "workspace:*", "@node-minify/utils": "workspace:*", }, "devDependencies": { diff --git a/packages/action/package.json b/packages/action/package.json index 5a1b6090f..9c5acfcbe 100644 --- a/packages/action/package.json +++ b/packages/action/package.json @@ -54,6 +54,10 @@ "@actions/github": "^6.0.0", "@node-minify/benchmark": "workspace:*", "@node-minify/core": "workspace:*", + "@node-minify/esbuild": "workspace:*", + "@node-minify/oxc": "workspace:*", + "@node-minify/swc": "workspace:*", + "@node-minify/terser": "workspace:*", "@node-minify/utils": "workspace:*" }, "devDependencies": { diff --git a/packages/action/src/outputs.ts b/packages/action/src/outputs.ts index 73742779f..27f908bad 100644 --- a/packages/action/src/outputs.ts +++ b/packages/action/src/outputs.ts @@ -21,7 +21,7 @@ export function setMinifyOutputs(result: MinifyResult): void { setOutput("time-ms", result.totalTimeMs); setOutput("report-json", JSON.stringify(result)); - if (result.files.length > 0 && result.files[0]?.gzipSize) { + if (result.files.some((f) => f.gzipSize !== undefined)) { const totalGzip = result.files.reduce( (sum, f) => sum + (f.gzipSize || 0), 0 From 508c293831771ad85ba1dbe856203c983f140342 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 21:40:17 +0100 Subject: [PATCH 26/49] feat(action): add benchmark outputs and improve input validation --- .github/workflows/release-action.yml | 5 ++++- packages/action/action.yml | 4 ++++ packages/action/src/benchmark.ts | 2 +- packages/action/src/inputs.ts | 12 +++++++++++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index 0a6a92c58..d68df31c3 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -80,6 +80,9 @@ jobs: git add -f packages/action/dist/ git commit -m "chore: build action dist for ${{ steps.version.outputs.version }}" + # Push the commit before tagging + git push origin HEAD:${{ github.ref_name }} + - name: Update major version tag run: | # Force update the major version tag (e.g., v1) @@ -100,7 +103,7 @@ jobs: zip -r ../node-minify-action-${{ steps.version.outputs.version }}.zip . - name: Upload release artifact - if: contains(fromJson('["release", "workflow_dispatch"]'), github.event_name) + if: github.event_name == 'release' uses: softprops/action-gh-release@v2 with: files: node-minify-action-${{ steps.version.outputs.version }}.zip diff --git a/packages/action/action.yml b/packages/action/action.yml index 54c12df00..1f7bd861a 100644 --- a/packages/action/action.yml +++ b/packages/action/action.yml @@ -81,6 +81,10 @@ outputs: description: "Full report as JSON string" benchmark-winner: description: "Best compressor from benchmark (if run)" + best-compression: + description: "Compressor with best compression ratio (if benchmark run)" + best-speed: + description: "Fastest compressor (if benchmark run)" runs: using: "node20" diff --git a/packages/action/src/benchmark.ts b/packages/action/src/benchmark.ts index 48667e33a..33eaa4cfc 100644 --- a/packages/action/src/benchmark.ts +++ b/packages/action/src/benchmark.ts @@ -52,7 +52,7 @@ export async function runBenchmark( success: r.success, size: r.sizeBytes, reduction: r.reductionPercent, - gzipSize: typeof r.gzipSize === "string" ? undefined : r.gzipSize, + gzipSize: r.gzipBytes, timeMs: r.timeMs, error: r.error, })), diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts index 5af20a454..6e6e8df2c 100644 --- a/packages/action/src/inputs.ts +++ b/packages/action/src/inputs.ts @@ -73,7 +73,17 @@ export function parseInputs(): ActionInputs { benchmark: getBooleanInput("benchmark"), benchmarkCompressors, failOnIncrease: getBooleanInput("fail-on-increase"), - minReduction: Number.parseFloat(getInput("min-reduction")) || 0, + minReduction: (() => { + const raw = getInput("min-reduction"); + if (!raw) return 0; + const value = Number.parseFloat(raw); + if (Number.isNaN(value)) { + throw new Error( + `Invalid 'min-reduction' input: '${raw}' is not a valid number (expected 0-100)` + ); + } + return value; + })(), includeGzip: getBooleanInput("include-gzip"), workingDirectory: getInput("working-directory") || ".", githubToken: getInput("github-token") || process.env.GITHUB_TOKEN, From 7bb9549a9a57dbef83f8b37b105774f462637cc2 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 21:42:02 +0100 Subject: [PATCH 27/49] ci(release): avoid empty commit when building action dist in release-action workflow --- .github/workflows/release-action.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index d68df31c3..e03be8094 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -78,10 +78,14 @@ jobs: # Force add dist (ignored by .gitignore) git add -f packages/action/dist/ - git commit -m "chore: build action dist for ${{ steps.version.outputs.version }}" - # Push the commit before tagging - git push origin HEAD:${{ github.ref_name }} + # Only commit and push if there are staged changes + if git diff --cached --quiet; then + echo "No changes to commit (dist files unchanged)" + else + git commit -m "chore: build action dist for ${{ steps.version.outputs.version }}" + git push origin HEAD:${{ github.ref_name }} + fi - name: Update major version tag run: | From 6cbf94dd65942cedd80ff42b2f0b0a57309aae4c Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 21:48:09 +0100 Subject: [PATCH 28/49] fix(action): improve input validation and push dist to tag in release --- .github/workflows/release-action.yml | 2 +- packages/action/src/inputs.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index e03be8094..9978aba9f 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -84,7 +84,7 @@ jobs: echo "No changes to commit (dist files unchanged)" else git commit -m "chore: build action dist for ${{ steps.version.outputs.version }}" - git push origin HEAD:${{ github.ref_name }} + git push origin HEAD:refs/tags/${{ steps.version.outputs.version }} fi - name: Update major version tag diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts index 6e6e8df2c..d1d4de3f9 100644 --- a/packages/action/src/inputs.ts +++ b/packages/action/src/inputs.ts @@ -77,7 +77,7 @@ export function parseInputs(): ActionInputs { const raw = getInput("min-reduction"); if (!raw) return 0; const value = Number.parseFloat(raw); - if (Number.isNaN(value)) { + if (Number.isNaN(value) || value < 0 || value > 100) { throw new Error( `Invalid 'min-reduction' input: '${raw}' is not a valid number (expected 0-100)` ); From 658bfc75c34c0d29f5cdb963e01830037ddc770c Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 21:56:47 +0100 Subject: [PATCH 29/49] fix(action): improve release workflow tag handling, artifact packaging, and cleanup unused code - Fix release tag to point at dist commit instead of creating branch ref - Update concurrency key to use tag identifier for both trigger types - Fix artifact packaging to match action.yml main path structure - Add missing best-compression and best-speed outputs to root action.yml - Remove unused JAVA_COMPRESSORS constant from inputs.ts --- .github/workflows/release-action.yml | 16 ++++++++++------ action.yml | 4 ++++ packages/action/src/inputs.ts | 3 +-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index 9978aba9f..71e466ba9 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -10,7 +10,7 @@ on: required: true type: string -concurrency: release-${{ github.ref_name }} +concurrency: release-${{ github.event.release.tag_name || github.event.inputs.tag || github.ref_name }} permissions: contents: write @@ -71,7 +71,7 @@ jobs: exit 1 fi - - name: Commit built action dist + - name: Commit and tag built action dist run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -79,14 +79,18 @@ jobs: # Force add dist (ignored by .gitignore) git add -f packages/action/dist/ - # Only commit and push if there are staged changes + # Only commit if there are staged changes if git diff --cached --quiet; then echo "No changes to commit (dist files unchanged)" else git commit -m "chore: build action dist for ${{ steps.version.outputs.version }}" - git push origin HEAD:refs/tags/${{ steps.version.outputs.version }} fi + # Update the release tag to point at HEAD (with dist files) and push + VERSION="${{ steps.version.outputs.version }}" + git tag -fa "$VERSION" -m "Release $VERSION with built dist" + git push origin "$VERSION" --force + - name: Update major version tag run: | # Force update the major version tag (e.g., v1) @@ -99,9 +103,9 @@ jobs: - name: Create action release artifact run: | - mkdir -p release-action + mkdir -p release-action/packages/action cp action.yml release-action/ - cp -r packages/action/dist release-action/ + cp -r packages/action/dist release-action/packages/action/ cp packages/action/README.md release-action/ cd release-action zip -r ../node-minify-action-${{ steps.version.outputs.version }}.zip . diff --git a/action.yml b/action.yml index 3900c1899..b5be86ec2 100644 --- a/action.yml +++ b/action.yml @@ -81,6 +81,10 @@ outputs: description: "Full report as JSON string" benchmark-winner: description: "Best compressor from benchmark (if run)" + best-compression: + description: "Compressor with best compression ratio (if benchmark run)" + best-speed: + description: "Fastest compressor (if benchmark run)" runs: using: "node20" diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts index d1d4de3f9..fed9e6fa3 100644 --- a/packages/action/src/inputs.ts +++ b/packages/action/src/inputs.ts @@ -9,7 +9,6 @@ import { isBuiltInCompressor } from "@node-minify/utils"; import type { ActionInputs } from "./types.ts"; const TYPE_REQUIRED_COMPRESSORS = ["esbuild", "lightningcss", "yui"]; -const JAVA_COMPRESSORS = ["gcc", "google-closure-compiler", "yui"]; const DEPRECATED_COMPRESSORS: Record = { "babel-minify": @@ -115,4 +114,4 @@ export function validateCompressor(compressor: string): void { export const validateJavaCompressor = validateCompressor; -export { DEPRECATED_COMPRESSORS, JAVA_COMPRESSORS, TYPE_REQUIRED_COMPRESSORS }; +export { DEPRECATED_COMPRESSORS, TYPE_REQUIRED_COMPRESSORS }; From fb28e33236b71587e33101d60d07a7c8d0323630 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 22:04:44 +0100 Subject: [PATCH 30/49] fix(benchmark): populate gzipBytes field for action compatibility The benchmark package was only setting gzipSize (string) but not gzipBytes (number). This caused the action's benchmark output to always have undefined gzip data. --- packages/benchmark/src/runner.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/benchmark/src/runner.ts b/packages/benchmark/src/runner.ts index 1dbda92df..ae485f69d 100644 --- a/packages/benchmark/src/runner.ts +++ b/packages/benchmark/src/runner.ts @@ -9,6 +9,7 @@ import { minify } from "@node-minify/core"; import { getFilesizeBrotliInBytes, getFilesizeGzippedInBytes, + getFilesizeGzippedRaw, prettyBytes, wildcards, } from "@node-minify/utils"; @@ -167,6 +168,7 @@ async function benchmarkCompressor( if (options.includeGzip) { metrics.gzipSize = await getFilesizeGzippedInBytes(lastOutputFile); + metrics.gzipBytes = await getFilesizeGzippedRaw(lastOutputFile); } if (options.includeBrotli) { From e1502899471f5cbbdc6ed5c68fccf0a43df37047 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 22:06:57 +0100 Subject: [PATCH 31/49] docs(test-action): document why compressor packages must be installed Compressors use native dependencies that cannot be bundled by Bun. They are dynamically imported at runtime via resolveCompressor(). Added TODO to investigate using @vercel/ncc or marking as external. --- .github/workflows/test-action.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index a79b148b7..2f0b78e46 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -65,6 +65,9 @@ jobs: node-version: "20" - name: Install compressor packages + # Required: Compressors use native dependencies that cannot be bundled. + # They are dynamically imported at runtime via resolveCompressor(). + # TODO: Investigate using @vercel/ncc or marking compressors as external. run: | mkdir -p /tmp/compressors && cd /tmp/compressors npm init -y @@ -168,6 +171,9 @@ jobs: node-version: "20" - name: Install compressor packages + # Required: Compressors use native dependencies that cannot be bundled. + # They are dynamically imported at runtime via resolveCompressor(). + # TODO: Investigate using @vercel/ncc or marking compressors as external. run: | mkdir -p /tmp/compressors && cd /tmp/compressors npm init -y From 2ef2d7f0eadae5af4dc87f2808cadb9d252d8347 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 22:08:04 +0100 Subject: [PATCH 32/49] refactor(action): remove misleading validateJavaCompressor alias The alias was just renaming validateCompressor without any Java-specific logic. Renamed usages to use validateCompressor directly for clarity. --- packages/action/src/index.ts | 4 ++-- packages/action/src/inputs.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/action/src/index.ts b/packages/action/src/index.ts index 58d72d4a6..057edbbc7 100644 --- a/packages/action/src/index.ts +++ b/packages/action/src/index.ts @@ -8,7 +8,7 @@ import { info, setFailed } from "@actions/core"; import { context } from "@actions/github"; import { runBenchmark } from "./benchmark.ts"; import { checkThresholds } from "./checks.ts"; -import { parseInputs, validateJavaCompressor } from "./inputs.ts"; +import { parseInputs, validateCompressor } from "./inputs.ts"; import { runMinification } from "./minify.ts"; import { setBenchmarkOutputs, setMinifyOutputs } from "./outputs.ts"; import { addAnnotations } from "./reporters/annotations.ts"; @@ -29,7 +29,7 @@ async function run(): Promise { try { const inputs = parseInputs(); - validateJavaCompressor(inputs.compressor); + validateCompressor(inputs.compressor); info(`Minifying ${inputs.input} with ${inputs.compressor}...`); diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts index fed9e6fa3..f4b503e5b 100644 --- a/packages/action/src/inputs.ts +++ b/packages/action/src/inputs.ts @@ -112,6 +112,5 @@ export function validateCompressor(compressor: string): void { } } -export const validateJavaCompressor = validateCompressor; export { DEPRECATED_COMPRESSORS, TYPE_REQUIRED_COMPRESSORS }; From 53f66a629a6ebe531ccf0c19ced327e56239527a Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 22:09:16 +0100 Subject: [PATCH 33/49] docs: use @v1 instead of @main for action version reference Following GitHub best practices to pin to major version tags. --- .github/actions/node-minify/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/node-minify/README.md b/.github/actions/node-minify/README.md index 7146ae4b9..e340b2dea 100644 --- a/.github/actions/node-minify/README.md +++ b/.github/actions/node-minify/README.md @@ -3,7 +3,7 @@ > **This action is deprecated.** Please use the new bundled action instead: > > ```yaml -> - uses: srod/node-minify@main +> - uses: srod/node-minify@v1 > ``` The new action includes: @@ -29,7 +29,7 @@ Replace: With: ```yaml -- uses: srod/node-minify@main +- uses: srod/node-minify@v1 with: input: "src/app.js" output: "dist/app.min.js" From f1c18cafe2cb83d9cb3471c021021c8fa310c4b7 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 22:11:12 +0100 Subject: [PATCH 34/49] fix(action): filter out type-required compressors from benchmark when type not provided Prevents benchmark failures when users run benchmark: true without specifying type, since esbuild/lightningcss/yui require it. --- packages/action/src/benchmark.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/action/src/benchmark.ts b/packages/action/src/benchmark.ts index 33eaa4cfc..189fb320c 100644 --- a/packages/action/src/benchmark.ts +++ b/packages/action/src/benchmark.ts @@ -8,6 +8,8 @@ import { resolve } from "node:path"; import { benchmark } from "@node-minify/benchmark"; import type { ActionInputs, BenchmarkResult } from "./types.ts"; +const TYPE_REQUIRED_COMPRESSORS = ["esbuild", "lightningcss", "yui"]; + /** * Run benchmark comparison across multiple compressors. * @@ -22,9 +24,16 @@ export async function runBenchmark( ): Promise { const inputPath = resolve(inputs.workingDirectory, inputs.input); + // Filter out compressors that require 'type' when type is not provided + const compressors = inputs.type + ? inputs.benchmarkCompressors + : inputs.benchmarkCompressors.filter( + (c) => !TYPE_REQUIRED_COMPRESSORS.includes(c) + ); + const result = await benchmark({ input: inputPath, - compressors: inputs.benchmarkCompressors, + compressors, includeGzip: inputs.includeGzip, type: inputs.type, iterations: 1, From c705213bb5cd64018a53eabca0dc7e15be4dc8bb Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 22:14:08 +0100 Subject: [PATCH 35/49] test(action): add comprehensive tests for inputs, outputs, and benchmark - Add tests for parseInputs validation, defaults, and error handling - Add tests for validateCompressor warnings - Add tests for setMinifyOutputs and setBenchmarkOutputs - Add tests for runBenchmark filtering and result mapping Increases test coverage from 6 to 24 tests. --- packages/action/__tests__/benchmark.test.ts | 156 ++++++++++++++++++++ packages/action/__tests__/inputs.test.ts | 154 +++++++++++++++++++ packages/action/__tests__/outputs.test.ts | 150 +++++++++++++++++++ 3 files changed, 460 insertions(+) create mode 100644 packages/action/__tests__/benchmark.test.ts create mode 100644 packages/action/__tests__/inputs.test.ts create mode 100644 packages/action/__tests__/outputs.test.ts diff --git a/packages/action/__tests__/benchmark.test.ts b/packages/action/__tests__/benchmark.test.ts new file mode 100644 index 000000000..99a9553f5 --- /dev/null +++ b/packages/action/__tests__/benchmark.test.ts @@ -0,0 +1,156 @@ +/*! node-minify action tests - MIT Licensed */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock @node-minify/benchmark +vi.mock("@node-minify/benchmark", () => ({ + benchmark: vi.fn(), +})); + +import { benchmark } from "@node-minify/benchmark"; +import { runBenchmark } from "../src/benchmark.ts"; +import type { ActionInputs } from "../src/types.ts"; + +describe("runBenchmark", () => { + const baseInputs: ActionInputs = { + input: "src/app.js", + output: "dist/app.min.js", + compressor: "terser", + options: {}, + reportSummary: false, + reportPRComment: false, + reportAnnotations: false, + benchmark: true, + benchmarkCompressors: ["terser", "esbuild", "swc", "oxc"], + failOnIncrease: false, + minReduction: 0, + includeGzip: true, + workingDirectory: ".", + }; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("filters out type-required compressors when type not provided", async () => { + vi.mocked(benchmark).mockResolvedValue({ + timestamp: new Date().toISOString(), + options: {}, + files: [ + { + file: "src/app.js", + originalSizeBytes: 10000, + originalSize: "10 KB", + results: [], + }, + ], + summary: { + bestCompression: "N/A", + bestPerformance: "N/A", + recommended: "N/A", + }, + }); + + await runBenchmark({ ...baseInputs, type: undefined }); + + expect(benchmark).toHaveBeenCalledWith( + expect.objectContaining({ + // esbuild should be filtered out (requires type) + compressors: ["terser", "swc", "oxc"], + }) + ); + }); + + test("includes all compressors when type is provided", async () => { + vi.mocked(benchmark).mockResolvedValue({ + timestamp: new Date().toISOString(), + options: {}, + files: [ + { + file: "src/app.js", + originalSizeBytes: 10000, + originalSize: "10 KB", + results: [], + }, + ], + summary: { + bestCompression: "N/A", + bestPerformance: "N/A", + recommended: "N/A", + }, + }); + + await runBenchmark({ ...baseInputs, type: "js" }); + + expect(benchmark).toHaveBeenCalledWith( + expect.objectContaining({ + compressors: ["terser", "esbuild", "swc", "oxc"], + }) + ); + }); + + test("maps benchmark results correctly", async () => { + vi.mocked(benchmark).mockResolvedValue({ + timestamp: new Date().toISOString(), + options: {}, + files: [ + { + file: "src/app.js", + originalSizeBytes: 10000, + originalSize: "10 KB", + results: [ + { + compressor: "terser", + success: true, + sizeBytes: 3000, + size: "3 KB", + reductionPercent: 70, + gzipBytes: 1200, + gzipSize: "1.2 KB", + timeMs: 150, + }, + ], + }, + ], + summary: { + bestCompression: "terser", + bestPerformance: "terser", + recommended: "terser", + }, + }); + + const result = await runBenchmark({ ...baseInputs, type: "js" }); + + expect(result.originalSize).toBe(10000); + expect(result.compressors[0]).toEqual({ + compressor: "terser", + success: true, + size: 3000, + reduction: 70, + gzipSize: 1200, + timeMs: 150, + error: undefined, + }); + expect(result.recommended).toBe("terser"); + expect(result.bestCompression).toBe("terser"); + expect(result.bestSpeed).toBe("terser"); + }); + + test("returns empty result when no file results", async () => { + vi.mocked(benchmark).mockResolvedValue({ + timestamp: new Date().toISOString(), + options: {}, + files: [], + summary: { + bestCompression: "N/A", + bestPerformance: "N/A", + recommended: "N/A", + }, + }); + + const result = await runBenchmark(baseInputs); + + expect(result.originalSize).toBe(0); + expect(result.compressors).toEqual([]); + }); +}); diff --git a/packages/action/__tests__/inputs.test.ts b/packages/action/__tests__/inputs.test.ts new file mode 100644 index 000000000..f9f16f439 --- /dev/null +++ b/packages/action/__tests__/inputs.test.ts @@ -0,0 +1,154 @@ +/*! node-minify action tests - MIT Licensed */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock @actions/core before importing inputs +vi.mock("@actions/core", () => ({ + getInput: vi.fn(), + getBooleanInput: vi.fn(), + warning: vi.fn(), +})); + +// Mock @node-minify/utils +vi.mock("@node-minify/utils", () => ({ + isBuiltInCompressor: vi.fn(), +})); + +import { getInput, getBooleanInput, warning } from "@actions/core"; +import { isBuiltInCompressor } from "@node-minify/utils"; +import { parseInputs, validateCompressor } from "../src/inputs.ts"; + +describe("parseInputs", () => { + beforeEach(() => { + vi.resetAllMocks(); + // Default mocks + vi.mocked(getInput).mockImplementation((name: string) => { + const defaults: Record = { + input: "src/app.js", + output: "dist/app.min.js", + compressor: "terser", + options: "{}", + "min-reduction": "0", + "working-directory": ".", + }; + return defaults[name] || ""; + }); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + const defaults: Record = { + "report-summary": true, + "report-pr-comment": false, + "report-annotations": false, + benchmark: false, + "fail-on-increase": false, + "include-gzip": true, + }; + return defaults[name] ?? false; + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + test("parses basic inputs correctly", () => { + const inputs = parseInputs(); + + expect(inputs.input).toBe("src/app.js"); + expect(inputs.output).toBe("dist/app.min.js"); + expect(inputs.compressor).toBe("terser"); + expect(inputs.reportSummary).toBe(true); + }); + + test("throws error for invalid JSON in options", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "options") return "not-valid-json"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow("Invalid JSON in 'options' input"); + }); + + test("throws error when type required but not provided", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "compressor") return "esbuild"; + if (name === "type") return ""; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow( + "Compressor 'esbuild' requires the 'type' input" + ); + }); + + test("throws error for invalid min-reduction value", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "min-reduction") return "abc"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow("Invalid 'min-reduction' input"); + }); + + test("throws error for out-of-range min-reduction", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "min-reduction") return "150"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow("Invalid 'min-reduction' input"); + }); + + test("parses benchmark compressors from comma-separated string", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "benchmark-compressors") return "terser, swc, oxc"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.benchmarkCompressors).toEqual(["terser", "swc", "oxc"]); + }); +}); + +describe("validateCompressor", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("warns for deprecated compressor", () => { + vi.mocked(isBuiltInCompressor).mockReturnValue(true); + + validateCompressor("babel-minify"); + + expect(warning).toHaveBeenCalledWith( + expect.stringContaining("Deprecated") + ); + }); + + test("warns for non-built-in compressor", () => { + vi.mocked(isBuiltInCompressor).mockReturnValue(false); + + validateCompressor("custom-compressor"); + + expect(warning).toHaveBeenCalledWith( + expect.stringContaining("not a built-in compressor") + ); + }); + + test("does not warn for valid built-in compressor", () => { + vi.mocked(isBuiltInCompressor).mockReturnValue(true); + + validateCompressor("terser"); + + expect(warning).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/action/__tests__/outputs.test.ts b/packages/action/__tests__/outputs.test.ts new file mode 100644 index 000000000..8ad5bb4ef --- /dev/null +++ b/packages/action/__tests__/outputs.test.ts @@ -0,0 +1,150 @@ +/*! node-minify action tests - MIT Licensed */ + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// Mock @actions/core before importing +vi.mock("@actions/core", () => ({ + setOutput: vi.fn(), +})); + +import { setOutput } from "@actions/core"; +import { setBenchmarkOutputs, setMinifyOutputs } from "../src/outputs.ts"; +import type { BenchmarkResult, MinifyResult } from "../src/types.ts"; + +describe("setMinifyOutputs", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("sets all basic outputs", () => { + const result: MinifyResult = { + files: [ + { + file: "app.js", + originalSize: 10000, + minifiedSize: 3000, + reduction: 70, + timeMs: 50, + }, + ], + compressor: "terser", + totalOriginalSize: 10000, + totalMinifiedSize: 3000, + totalReduction: 70, + totalTimeMs: 50, + }; + + setMinifyOutputs(result); + + expect(setOutput).toHaveBeenCalledWith("original-size", 10000); + expect(setOutput).toHaveBeenCalledWith("minified-size", 3000); + expect(setOutput).toHaveBeenCalledWith("reduction-percent", "70.00"); + expect(setOutput).toHaveBeenCalledWith("time-ms", 50); + expect(setOutput).toHaveBeenCalledWith( + "report-json", + JSON.stringify(result) + ); + }); + + test("sets gzip-size when any file has gzipSize", () => { + const result: MinifyResult = { + files: [ + { + file: "a.js", + originalSize: 5000, + minifiedSize: 1500, + reduction: 70, + timeMs: 25, + }, + { + file: "b.js", + originalSize: 5000, + minifiedSize: 1500, + reduction: 70, + gzipSize: 800, + timeMs: 25, + }, + ], + compressor: "terser", + totalOriginalSize: 10000, + totalMinifiedSize: 3000, + totalReduction: 70, + totalTimeMs: 50, + }; + + setMinifyOutputs(result); + + expect(setOutput).toHaveBeenCalledWith("gzip-size", 800); + }); + + test("does not set gzip-size when no file has gzipSize", () => { + const result: MinifyResult = { + files: [ + { + file: "app.js", + originalSize: 10000, + minifiedSize: 3000, + reduction: 70, + timeMs: 50, + }, + ], + compressor: "terser", + totalOriginalSize: 10000, + totalMinifiedSize: 3000, + totalReduction: 70, + totalTimeMs: 50, + }; + + setMinifyOutputs(result); + + expect(setOutput).not.toHaveBeenCalledWith( + "gzip-size", + expect.anything() + ); + }); +}); + +describe("setBenchmarkOutputs", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("sets benchmark winner when recommended", () => { + const result: BenchmarkResult = { + file: "app.js", + originalSize: 10000, + compressors: [], + recommended: "terser", + bestCompression: "terser", + bestSpeed: "esbuild", + }; + + setBenchmarkOutputs(result); + + expect(setOutput).toHaveBeenCalledWith("benchmark-winner", "terser"); + expect(setOutput).toHaveBeenCalledWith("best-compression", "terser"); + expect(setOutput).toHaveBeenCalledWith("best-speed", "esbuild"); + }); + + test("does not set outputs when values are undefined", () => { + const result: BenchmarkResult = { + file: "app.js", + originalSize: 10000, + compressors: [], + recommended: undefined, + bestCompression: undefined, + bestSpeed: undefined, + }; + + setBenchmarkOutputs(result); + + expect(setOutput).not.toHaveBeenCalledWith( + "benchmark-winner", + expect.anything() + ); + expect(setOutput).toHaveBeenCalledWith( + "benchmark-json", + JSON.stringify(result) + ); + }); +}); From 1af49d90315c52510cbed31d3c0a1eea19a0475d Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 22:15:18 +0100 Subject: [PATCH 36/49] fix(benchmark): only pass type to minify when defined Prevents passing undefined as type which causes errors for compressors that require it (esbuild, lightningcss, yui). --- packages/benchmark/src/runner.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/benchmark/src/runner.ts b/packages/benchmark/src/runner.ts index ae485f69d..31dce2d6b 100644 --- a/packages/benchmark/src/runner.ts +++ b/packages/benchmark/src/runner.ts @@ -127,7 +127,7 @@ async function benchmarkCompressor( compressor, input: file, output: warmupFile, - type: options.type as "js" | "css", + ...(options.type && { type: options.type as "js" | "css" }), options: options.compressorOptions, }); } @@ -143,7 +143,7 @@ async function benchmarkCompressor( compressor, input: file, output: lastOutputFile, - type: options.type as "js" | "css", + ...(options.type && { type: options.type as "js" | "css" }), options: options.compressorOptions, }); times.push(performance.now() - start); From 03600543a42ed1cfb57f9d711ba9c73366a0127e Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 22:28:14 +0100 Subject: [PATCH 37/49] fix(action): remove lightningcss from type-required compressors - lightningcss is a pure CSS compressor that doesn't need the type param - Deduplicate TYPE_REQUIRED_COMPRESSORS by importing from inputs.ts - Update action.yml descriptions to reflect accurate requirements --- action.yml | 2 +- packages/action/action.yml | 2 +- packages/action/src/benchmark.ts | 2 +- packages/action/src/inputs.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/action.yml b/action.yml index b5be86ec2..516271f16 100644 --- a/action.yml +++ b/action.yml @@ -20,7 +20,7 @@ inputs: required: false default: "terser" type: - description: "File type: js or css (required for esbuild, lightningcss, yui)" + description: "File type: js or css (required for esbuild, yui)" required: false options: description: "Compressor-specific options (JSON string)" diff --git a/packages/action/action.yml b/packages/action/action.yml index 1f7bd861a..9f926c513 100644 --- a/packages/action/action.yml +++ b/packages/action/action.yml @@ -20,7 +20,7 @@ inputs: required: false default: "terser" type: - description: "File type: js or css (required for esbuild, lightningcss, yui)" + description: "File type: js or css (required for esbuild, yui)" required: false options: description: "Compressor-specific options (JSON string)" diff --git a/packages/action/src/benchmark.ts b/packages/action/src/benchmark.ts index 189fb320c..96f3fd21c 100644 --- a/packages/action/src/benchmark.ts +++ b/packages/action/src/benchmark.ts @@ -6,9 +6,9 @@ import { resolve } from "node:path"; import { benchmark } from "@node-minify/benchmark"; +import { TYPE_REQUIRED_COMPRESSORS } from "./inputs.ts"; import type { ActionInputs, BenchmarkResult } from "./types.ts"; -const TYPE_REQUIRED_COMPRESSORS = ["esbuild", "lightningcss", "yui"]; /** * Run benchmark comparison across multiple compressors. diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts index f4b503e5b..0bf2c7a96 100644 --- a/packages/action/src/inputs.ts +++ b/packages/action/src/inputs.ts @@ -8,7 +8,7 @@ import { getBooleanInput, getInput, warning } from "@actions/core"; import { isBuiltInCompressor } from "@node-minify/utils"; import type { ActionInputs } from "./types.ts"; -const TYPE_REQUIRED_COMPRESSORS = ["esbuild", "lightningcss", "yui"]; +const TYPE_REQUIRED_COMPRESSORS = ["esbuild", "yui"]; const DEPRECATED_COMPRESSORS: Record = { "babel-minify": From 829aae8e17289777ebfc332e59ccd46f5ecbe36a Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 22:37:57 +0100 Subject: [PATCH 38/49] fix(action): improve input parsing robustness and security - Validate type input explicitly instead of unsafe cast (js/css only) - Parse benchmark-compressors with deduplication and empty filtering - Don't leak raw JSON in options parse error messages - Add 7 new edge case tests (31 total) --- packages/action/__tests__/inputs.test.ts | 113 +++++++++++++++++++++++ packages/action/src/inputs.ts | 39 ++++++-- 2 files changed, 145 insertions(+), 7 deletions(-) diff --git a/packages/action/__tests__/inputs.test.ts b/packages/action/__tests__/inputs.test.ts index f9f16f439..686feec07 100644 --- a/packages/action/__tests__/inputs.test.ts +++ b/packages/action/__tests__/inputs.test.ts @@ -152,3 +152,116 @@ describe("validateCompressor", () => { expect(warning).not.toHaveBeenCalled(); }); }); + +describe("parseInputs edge cases", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getBooleanInput).mockImplementation((name: string) => { + const defaults: Record = { + "report-summary": true, + "report-pr-comment": false, + "report-annotations": false, + benchmark: false, + "fail-on-increase": false, + "include-gzip": true, + }; + return defaults[name] ?? false; + }); + }); + + test("throws error for invalid type value", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "type") return "invalid"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow( + "Invalid 'type' input: 'invalid' (expected 'js' or 'css')" + ); + }); + + test("accepts valid type 'js'", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "type") return "js"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.type).toBe("js"); + }); + + test("accepts valid type 'css'", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "type") return "css"; + if (name === "input") return "src/app.css"; + if (name === "output") return "dist/app.min.css"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.type).toBe("css"); + }); + + test("deduplicates benchmark compressors", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "benchmark-compressors") + return "terser, terser, swc, swc"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.benchmarkCompressors).toEqual(["terser", "swc"]); + }); + + test("filters empty strings from benchmark compressors", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "benchmark-compressors") return "terser,,swc, ,oxc"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.benchmarkCompressors).toEqual(["terser", "swc", "oxc"]); + }); + + test("falls back to defaults when all benchmark compressors are empty", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "benchmark-compressors") return ", , ,"; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + const inputs = parseInputs(); + expect(inputs.benchmarkCompressors).toEqual([ + "terser", + "esbuild", + "swc", + "oxc", + ]); + }); + + test("does not leak raw JSON in error message", () => { + vi.mocked(getInput).mockImplementation((name: string) => { + if (name === "options") return '{"secret": "password123'; + if (name === "input") return "src/app.js"; + if (name === "output") return "dist/app.min.js"; + return ""; + }); + + expect(() => parseInputs()).toThrow(/Invalid JSON in 'options' input/); + // Should NOT contain the actual input value + try { + parseInputs(); + } catch (err) { + expect((err as Error).message).not.toContain("password123"); + } + }); +}); diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts index 0bf2c7a96..193d8a472 100644 --- a/packages/action/src/inputs.ts +++ b/packages/action/src/inputs.ts @@ -34,10 +34,22 @@ const DEPRECATED_COMPRESSORS: Record = { * @throws Error if a compressor that requires a `type` is selected but `type` * is not provided. * @throws Error if the `options` input is present but is not valid JSON. + * @throws Error if the `type` input is provided but is not 'js' or 'css'. */ export function parseInputs(): ActionInputs { const compressor = getInput("compressor") || "terser"; - const type = getInput("type") as "js" | "css" | undefined; + + // Validate type input explicitly + const typeRaw = getInput("type"); + let type: "js" | "css" | undefined; + if (typeRaw) { + if (typeRaw !== "js" && typeRaw !== "css") { + throw new Error( + `Invalid 'type' input: '${typeRaw}' (expected 'js' or 'css')` + ); + } + type = typeRaw; + } if (TYPE_REQUIRED_COMPRESSORS.includes(compressor) && !type) { throw new Error( @@ -45,26 +57,39 @@ export function parseInputs(): ActionInputs { ); } + // Parse options JSON without leaking raw input in error messages let options: Record = {}; const optionsJson = getInput("options"); if (optionsJson) { try { options = JSON.parse(optionsJson); - } catch { - throw new Error(`Invalid JSON in 'options' input: ${optionsJson}`); + } catch (err) { + throw new Error( + `Invalid JSON in 'options' input: ${err instanceof Error ? err.message : String(err)}` + ); } } + // Parse benchmark compressors with deduplication and empty string filtering const benchmarkCompressorsInput = getInput("benchmark-compressors"); - const benchmarkCompressors = benchmarkCompressorsInput - ? benchmarkCompressorsInput.split(",").map((c: string) => c.trim()) - : ["terser", "esbuild", "swc", "oxc"]; + const benchmarkCompressors = (() => { + if (!benchmarkCompressorsInput) { + return ["terser", "esbuild", "swc", "oxc"]; + } + const parsed = benchmarkCompressorsInput + .split(",") + .map((c) => c.trim()) + .filter((c) => c.length > 0); + // Deduplicate while preserving order + const unique = [...new Set(parsed)]; + return unique.length > 0 ? unique : ["terser", "esbuild", "swc", "oxc"]; + })(); return { input: getInput("input", { required: true }), output: getInput("output", { required: true }), compressor, - type: type || undefined, + type, options, reportSummary: getBooleanInput("report-summary"), reportPRComment: getBooleanInput("report-pr-comment"), From 9973c64f462b5cf59f8eae9a4ced5a4c0677a1dc Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 22:43:14 +0100 Subject: [PATCH 39/49] docs: remove 'requires type' for lightningcss across all documentation lightningcss is a CSS-only compressor that doesn't need the type param. Updated docs, action.yml, CLI, and AGENTS.md to reflect this. --- .github/actions/node-minify/action.yml | 8 ++++---- .github/actions/node-minify/minify.ts | 2 +- AGENTS.md | 2 +- docs/src/content/docs/benchmark.md | 2 +- docs/src/content/docs/github-action.md | 3 +-- packages/action/README.md | 2 +- packages/cli/__tests__/cli.test.ts | 2 +- packages/cli/src/bin/cli.ts | 2 +- 8 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/actions/node-minify/action.yml b/.github/actions/node-minify/action.yml index 5d1a9eb6c..210d0f40a 100644 --- a/.github/actions/node-minify/action.yml +++ b/.github/actions/node-minify/action.yml @@ -1,5 +1,5 @@ name: "node-minify (deprecated)" -description: "DEPRECATED: Use srod/node-minify@main instead. This composite action will be removed in a future release." +description: "DEPRECATED: Use srod/node-minify@v1 instead. This composite action will be removed in a future release." author: "srod" branding: icon: "minimize-2" @@ -20,7 +20,7 @@ inputs: description: "Output file path" required: true type: - description: "File type: js or css (required for esbuild, lightningcss, yui)" + description: "File type: js or css (required for esbuild, yui)" required: false options: description: "Compressor-specific options (JSON string)" @@ -70,7 +70,7 @@ runs: - name: Deprecation warning shell: bash run: | - echo "::warning::This action (.github/actions/node-minify) is DEPRECATED. Please migrate to 'uses: srod/node-minify@main' for the new bundled action with more features (PR comments, annotations, benchmarking)." + echo "::warning::This action (.github/actions/node-minify) is DEPRECATED. Please migrate to 'uses: srod/node-minify@v1' for the new bundled action with more features (PR comments, annotations, benchmarking)." - name: Setup Java (for gcc/yui) if: contains(fromJSON('["gcc", "google-closure-compiler", "yui"]'), inputs.compressor) @@ -137,6 +137,6 @@ runs: | **Gzip Size** | ${{ steps.minify.outputs.gzip-size-formatted }} | | **Time** | ${{ steps.minify.outputs.time-ms }}ms | - > **Note:** This action is deprecated. Please migrate to \`uses: srod/node-minify@main\` for enhanced features. + > **Note:** This action is deprecated. Please migrate to \`uses: srod/node-minify@v1\` for enhanced features. EOF diff --git a/.github/actions/node-minify/minify.ts b/.github/actions/node-minify/minify.ts index 0987f9f74..43299c727 100644 --- a/.github/actions/node-minify/minify.ts +++ b/.github/actions/node-minify/minify.ts @@ -109,7 +109,7 @@ async function run(): Promise { console.log(`Minifying ${inputFile} with ${label}...`); - const requiresType = ["esbuild", "lightningcss", "yui"].includes( + const requiresType = ["esbuild", "yui"].includes( compressorName ); if (requiresType && !fileType) { diff --git a/AGENTS.md b/AGENTS.md index 6cfc1fff1..25df81965 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -304,7 +304,7 @@ The repository includes a reusable GitHub Action at `.github/actions/node-minify input: "src/app.js" output: "dist/app.min.js" compressor: "terser" # or esbuild, swc, lightningcss, etc. - type: "js" # required for esbuild, lightningcss, yui + type: "js" # required for esbuild, yui ``` ### Key Behaviors diff --git a/docs/src/content/docs/benchmark.md b/docs/src/content/docs/benchmark.md index 3d35b59c4..f67a796af 100644 --- a/docs/src/content/docs/benchmark.md +++ b/docs/src/content/docs/benchmark.md @@ -51,7 +51,7 @@ node-minify benchmark src/app.js -c terser,esbuild -n 3 # Include gzip size in results node-minify benchmark src/app.js -c terser,esbuild --gzip -# Specify file type (required for esbuild, lightningcss) +# Specify file type (required for esbuild) node-minify benchmark src/app.js -c terser,esbuild -t js ``` diff --git a/docs/src/content/docs/github-action.md b/docs/src/content/docs/github-action.md index ca47b942d..32272195e 100644 --- a/docs/src/content/docs/github-action.md +++ b/docs/src/content/docs/github-action.md @@ -125,7 +125,6 @@ jobs: The `type` parameter is **required** for: - `esbuild` (specify `js` or `css`) -- `lightningcss` (specify `css`) - `yui` (specify `js` or `css`) ### Available Compressors @@ -139,7 +138,7 @@ The `type` parameter is **required** for: - `google-closure-compiler` / `gcc` (requires Java) **CSS:** -- `lightningcss` (recommended, requires `type: css`) +- `lightningcss` (recommended, CSS-only) - `clean-css` - `cssnano` - `csso` diff --git a/packages/action/README.md b/packages/action/README.md index ee59f2511..65bb6333b 100644 --- a/packages/action/README.md +++ b/packages/action/README.md @@ -80,7 +80,7 @@ GitHub Action for minifying JavaScript, CSS, and HTML files with detailed report - `google-closure-compiler` / `gcc` (requires Java) **CSS:** -- `lightningcss` (recommended, requires `type: css`) +- `lightningcss` (recommended, CSS-only) - `clean-css` - `cssnano` - `csso` diff --git a/packages/cli/__tests__/cli.test.ts b/packages/cli/__tests__/cli.test.ts index 32b171d14..537ac3056 100644 --- a/packages/cli/__tests__/cli.test.ts +++ b/packages/cli/__tests__/cli.test.ts @@ -86,7 +86,7 @@ describe("CSS compressors", () => { expect(spy).toHaveBeenCalled(); }); - test("should minify with lightningcss (requires type)", async () => { + test("should minify with lightningcss", async () => { const spy = vi.spyOn(cli, "run"); await cli.run({ compressor: "lightningcss", diff --git a/packages/cli/src/bin/cli.ts b/packages/cli/src/bin/cli.ts index eea2092f1..b8bbd352e 100644 --- a/packages/cli/src/bin/cli.ts +++ b/packages/cli/src/bin/cli.ts @@ -49,7 +49,7 @@ function setupProgram(): Command { .option("-o, --output [file]", "output file path") .option( "-t, --type [type]", - "file type: js or css (required for esbuild, lightningcss, yui)" + "file type: js or css (required for esbuild, yui)" ) .option("-s, --silence", "no output will be printed") .option( From 9f1ced394667c8ad002f2e02646f9874c97efb8c Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 23:14:40 +0100 Subject: [PATCH 40/49] feat(action): add benchmark-json output and improve release workflow --- .github/workflows/release-action.yml | 8 ++++++++ action.yml | 2 ++ packages/action/README.md | 4 ++++ packages/action/action.yml | 2 ++ 4 files changed, 16 insertions(+) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index 71e466ba9..7bb457f77 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -24,6 +24,7 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.tag || (github.event.release.tag_name || github.ref) }} + fetch-depth: 0 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -71,6 +72,13 @@ jobs: exit 1 fi + - name: Ensure we're on a branch + run: | + # If in detached HEAD, create a temporary branch + if ! git symbolic-ref -q HEAD; then + git checkout -b temp-release-branch + fi + - name: Commit and tag built action dist run: | git config user.name "github-actions[bot]" diff --git a/action.yml b/action.yml index 516271f16..f451a9f04 100644 --- a/action.yml +++ b/action.yml @@ -85,6 +85,8 @@ outputs: description: "Compressor with best compression ratio (if benchmark run)" best-speed: description: "Fastest compressor (if benchmark run)" + benchmark-json: + description: "Full benchmark results as JSON string" runs: using: "node20" diff --git a/packages/action/README.md b/packages/action/README.md index 65bb6333b..99a48c406 100644 --- a/packages/action/README.md +++ b/packages/action/README.md @@ -118,6 +118,10 @@ GitHub Action for minifying JavaScript, CSS, and HTML files with detailed report | `gzip-size` | Gzipped size in bytes | | `time-ms` | Compression time in ms | | `report-json` | Full report as JSON | +| `benchmark-winner` | Best compressor from benchmark (if run) | +| `best-compression` | Compressor with best compression ratio (if benchmark run) | +| `best-speed` | Fastest compressor (if benchmark run) | +| `benchmark-json` | Full benchmark results as JSON string | ## License diff --git a/packages/action/action.yml b/packages/action/action.yml index 9f926c513..b08b600c0 100644 --- a/packages/action/action.yml +++ b/packages/action/action.yml @@ -85,6 +85,8 @@ outputs: description: "Compressor with best compression ratio (if benchmark run)" best-speed: description: "Fastest compressor (if benchmark run)" + benchmark-json: + description: "Full benchmark results as JSON string" runs: using: "node20" From 8f451e6247ad091d85ac3bb2cf92407211f61752 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Sat, 10 Jan 2026 23:25:31 +0100 Subject: [PATCH 41/49] ci(action): ensure release tag is only updated if dist changes --- .github/workflows/release-action.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index 7bb457f77..2558fa493 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -79,7 +79,8 @@ jobs: git checkout -b temp-release-branch fi - - name: Commit and tag built action dist + - name: Commit built action dist + id: commit run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -90,10 +91,15 @@ jobs: # Only commit if there are staged changes if git diff --cached --quiet; then echo "No changes to commit (dist files unchanged)" + echo "skip_tag_update=true" >> $GITHUB_OUTPUT else git commit -m "chore: build action dist for ${{ steps.version.outputs.version }}" + echo "skip_tag_update=false" >> $GITHUB_OUTPUT fi + - name: Update release tag + if: steps.commit.outputs.skip_tag_update != 'true' + run: | # Update the release tag to point at HEAD (with dist files) and push VERSION="${{ steps.version.outputs.version }}" git tag -fa "$VERSION" -m "Release $VERSION with built dist" From 5ce66455e46e6789aed0280804b04d8d40cb9694 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Tue, 13 Jan 2026 19:15:56 +0100 Subject: [PATCH 42/49] ci(action): use explicit refspecs for pushing release tags --- .github/workflows/release-action.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-action.yml b/.github/workflows/release-action.yml index 2558fa493..6e9f393e0 100644 --- a/.github/workflows/release-action.yml +++ b/.github/workflows/release-action.yml @@ -103,14 +103,14 @@ jobs: # Update the release tag to point at HEAD (with dist files) and push VERSION="${{ steps.version.outputs.version }}" git tag -fa "$VERSION" -m "Release $VERSION with built dist" - git push origin "$VERSION" --force + git push origin "refs/tags/$VERSION" --force - name: Update major version tag run: | # Force update the major version tag (e.g., v1) MAJOR="${{ steps.version.outputs.major }}" git tag -fa "$MAJOR" -m "Update $MAJOR tag to ${{ steps.version.outputs.version }}" - if ! git push origin "$MAJOR" --force; then + if ! git push origin "refs/tags/$MAJOR" --force; then echo "Error: Failed to push major version tag $MAJOR" exit 1 fi From 512da8ff4f5ac5380f4d7e4f6a8d0ad1f23156d2 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Tue, 13 Jan 2026 19:19:01 +0100 Subject: [PATCH 43/49] fix(action): throw descriptive error when benchmark yields no results --- packages/action/src/benchmark.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/action/src/benchmark.ts b/packages/action/src/benchmark.ts index 96f3fd21c..ed6336868 100644 --- a/packages/action/src/benchmark.ts +++ b/packages/action/src/benchmark.ts @@ -9,7 +9,6 @@ import { benchmark } from "@node-minify/benchmark"; import { TYPE_REQUIRED_COMPRESSORS } from "./inputs.ts"; import type { ActionInputs, BenchmarkResult } from "./types.ts"; - /** * Run benchmark comparison across multiple compressors. * @@ -43,14 +42,10 @@ export async function runBenchmark( // The benchmark package returns results per-file, we take the first file const fileResult = result.files[0]; if (!fileResult) { - return { - file: inputs.input, - originalSize: 0, - compressors: [], - recommended: undefined, - bestCompression: undefined, - bestSpeed: undefined, - }; + throw new Error( + `Benchmark failed: no results for input "${inputs.input}". ` + + "Check that the file exists and at least one compressor succeeded." + ); } return { From 250f68854e206ef609152eb3352519c1f055522f Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Tue, 13 Jan 2026 19:20:56 +0100 Subject: [PATCH 44/49] docs(action): document compressor package installation requirement --- .github/workflows/test-action.yml | 11 +++++------ packages/action/README.md | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-action.yml b/.github/workflows/test-action.yml index 2f0b78e46..ba13c8d18 100644 --- a/.github/workflows/test-action.yml +++ b/.github/workflows/test-action.yml @@ -65,9 +65,9 @@ jobs: node-version: "20" - name: Install compressor packages - # Required: Compressors use native dependencies that cannot be bundled. - # They are dynamically imported at runtime via resolveCompressor(). - # TODO: Investigate using @vercel/ncc or marking compressors as external. + # Compressors contain native dependencies (esbuild binaries, swc binaries, etc.) + # that cannot be bundled. Users must install the compressor package they need. + # See packages/action/README.md for documentation. run: | mkdir -p /tmp/compressors && cd /tmp/compressors npm init -y @@ -171,9 +171,8 @@ jobs: node-version: "20" - name: Install compressor packages - # Required: Compressors use native dependencies that cannot be bundled. - # They are dynamically imported at runtime via resolveCompressor(). - # TODO: Investigate using @vercel/ncc or marking compressors as external. + # Compressors contain native dependencies that cannot be bundled. + # See packages/action/README.md for documentation. run: | mkdir -p /tmp/compressors && cd /tmp/compressors npm init -y diff --git a/packages/action/README.md b/packages/action/README.md index 99a48c406..711c79eb4 100644 --- a/packages/action/README.md +++ b/packages/action/README.md @@ -13,6 +13,27 @@ GitHub Action for minifying JavaScript, CSS, and HTML files with detailed report ## Usage +### Prerequisites + +Compressor packages contain native dependencies that cannot be bundled into the action. You must install the compressor package you want to use before running the action: + +```yaml +- name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + +- name: Install compressor + run: npm install @node-minify/terser + +- name: Minify JavaScript + uses: srod/node-minify@v1 + with: + input: "src/app.js" + output: "dist/app.min.js" + compressor: "terser" +``` + ### Basic Minification ```yaml From a6a7a0dfc30535edcb5c163a8532b67838e50440 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Tue, 13 Jan 2026 21:21:44 +0100 Subject: [PATCH 45/49] test(action): update benchmark test to expect error on empty results --- .github/actions/node-minify/minify.ts | 4 +--- packages/action/__tests__/benchmark.test.ts | 9 ++++----- packages/action/__tests__/inputs.test.ts | 2 +- packages/action/src/inputs.ts | 1 - 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/actions/node-minify/minify.ts b/.github/actions/node-minify/minify.ts index 43299c727..2bec5b456 100644 --- a/.github/actions/node-minify/minify.ts +++ b/.github/actions/node-minify/minify.ts @@ -109,9 +109,7 @@ async function run(): Promise { console.log(`Minifying ${inputFile} with ${label}...`); - const requiresType = ["esbuild", "yui"].includes( - compressorName - ); + const requiresType = ["esbuild", "yui"].includes(compressorName); if (requiresType && !fileType) { console.error( `::error::Compressor '${compressorName}' requires the 'type' input (js or css)` diff --git a/packages/action/__tests__/benchmark.test.ts b/packages/action/__tests__/benchmark.test.ts index 99a9553f5..cb17df97c 100644 --- a/packages/action/__tests__/benchmark.test.ts +++ b/packages/action/__tests__/benchmark.test.ts @@ -136,7 +136,7 @@ describe("runBenchmark", () => { expect(result.bestSpeed).toBe("terser"); }); - test("returns empty result when no file results", async () => { + test("throws error when no file results", async () => { vi.mocked(benchmark).mockResolvedValue({ timestamp: new Date().toISOString(), options: {}, @@ -148,9 +148,8 @@ describe("runBenchmark", () => { }, }); - const result = await runBenchmark(baseInputs); - - expect(result.originalSize).toBe(0); - expect(result.compressors).toEqual([]); + await expect(runBenchmark(baseInputs)).rejects.toThrow( + 'Benchmark failed: no results for input "src/app.js"' + ); }); }); diff --git a/packages/action/__tests__/inputs.test.ts b/packages/action/__tests__/inputs.test.ts index 686feec07..b4b16ffe4 100644 --- a/packages/action/__tests__/inputs.test.ts +++ b/packages/action/__tests__/inputs.test.ts @@ -14,7 +14,7 @@ vi.mock("@node-minify/utils", () => ({ isBuiltInCompressor: vi.fn(), })); -import { getInput, getBooleanInput, warning } from "@actions/core"; +import { getBooleanInput, getInput, warning } from "@actions/core"; import { isBuiltInCompressor } from "@node-minify/utils"; import { parseInputs, validateCompressor } from "../src/inputs.ts"; diff --git a/packages/action/src/inputs.ts b/packages/action/src/inputs.ts index 193d8a472..87dd1573f 100644 --- a/packages/action/src/inputs.ts +++ b/packages/action/src/inputs.ts @@ -137,5 +137,4 @@ export function validateCompressor(compressor: string): void { } } - export { DEPRECATED_COMPRESSORS, TYPE_REQUIRED_COMPRESSORS }; From 4b465d81d71f9393fcaa39bffb8ec83df4b2151b Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Tue, 13 Jan 2026 21:30:23 +0100 Subject: [PATCH 46/49] fix: improve gzip size stream handling and action documentation --- .github/actions/node-minify/README.md | 13 +++++++++++ packages/action/package.json | 2 +- packages/action/src/types.ts | 5 ++++ .../utils/src/getFilesizeGzippedInBytes.ts | 23 ++++++++++++++++--- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/actions/node-minify/README.md b/.github/actions/node-minify/README.md index e340b2dea..d65040d9a 100644 --- a/.github/actions/node-minify/README.md +++ b/.github/actions/node-minify/README.md @@ -68,6 +68,19 @@ The following documentation is for the deprecated composite action. | `include-gzip` | Include gzip sizes | No | `true` | | `java-version` | Java version for gcc/yui | No | - | +> **Note about `java-version`:** This input is **not supported** in the new bundled action (`srod/node-minify@v1`). The new action relies on GitHub runners having Java pre-installed. If you need Java compressors (`gcc` or `yui`), use `actions/setup-java` before running the action: +> +> ```yaml +> - uses: actions/setup-java@v4 +> with: +> distribution: 'temurin' +> java-version: '17' +> - uses: srod/node-minify@v1 +> with: +> compressor: gcc +> # ... +> ``` + ### Outputs | Output | Description | diff --git a/packages/action/package.json b/packages/action/package.json index 9c5acfcbe..b252167c5 100644 --- a/packages/action/package.json +++ b/packages/action/package.json @@ -40,7 +40,7 @@ "url": "https://github.com/srod/node-minify/issues" }, "scripts": { - "build": "bun build src/index.ts --outdir dist --target node --format esm --bundle --minify && cp src/index.d.ts dist/index.d.ts 2>/dev/null || true", + "build": "bun build src/index.ts --outdir dist --target node --format esm --bundle --minify && cp src/index.d.ts dist/index.d.ts", "build:ncc": "ncc build src/index.ts -o dist --source-map --license licenses.txt", "format:check": "biome check .", "lint": "biome lint .", diff --git a/packages/action/src/types.ts b/packages/action/src/types.ts index 0b64e76eb..5117e0e26 100644 --- a/packages/action/src/types.ts +++ b/packages/action/src/types.ts @@ -8,6 +8,11 @@ export interface ActionInputs { input: string; output: string; compressor: string; + /** + * File type hint for compressors that handle multiple types. + * Only required for `esbuild` (supports both JS and CSS) and deprecated `yui`. + * Other compressors auto-detect or only support one type. + */ type?: "js" | "css"; options: Record; reportSummary: boolean; diff --git a/packages/utils/src/getFilesizeGzippedInBytes.ts b/packages/utils/src/getFilesizeGzippedInBytes.ts index 6ead38b5a..ae7bb6802 100644 --- a/packages/utils/src/getFilesizeGzippedInBytes.ts +++ b/packages/utils/src/getFilesizeGzippedInBytes.ts @@ -36,12 +36,29 @@ async function getGzipSize(file: string): Promise { const { gzipSizeStream } = await import("gzip-size"); const source = createReadStream(file); + const gzip = gzipSizeStream(); return new Promise((resolve, reject) => { + const cleanup = () => { + source.destroy(); + gzip.destroy(); + }; + + source.on("error", (err) => { + cleanup(); + reject(err); + }); + source - .pipe(gzipSizeStream()) - .on("gzip-size", resolve) - .on("error", reject); + .pipe(gzip) + .on("gzip-size", (size: number) => { + cleanup(); + resolve(size); + }) + .on("error", (err) => { + cleanup(); + reject(err); + }); }); } From 3d4d2d0e48f9013c6ca32143e037bf2b420aa4d7 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Tue, 13 Jan 2026 21:30:51 +0100 Subject: [PATCH 47/49] docs: add changeset for gzip and action fixes --- .changeset/fix-gzip-stream-and-action-build.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/fix-gzip-stream-and-action-build.md diff --git a/.changeset/fix-gzip-stream-and-action-build.md b/.changeset/fix-gzip-stream-and-action-build.md new file mode 100644 index 000000000..5c612d70a --- /dev/null +++ b/.changeset/fix-gzip-stream-and-action-build.md @@ -0,0 +1,8 @@ +--- +"@node-minify/utils": patch +"@node-minify/action": patch +--- + +fix: improve gzip size stream handling in utils +fix: ensure action build fails if type definitions copy fails +docs: add documentation for action inputs and java-version migration From 3f53cf5885f1aa26eee34cc61beb603ac6a4c971 Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Tue, 13 Jan 2026 21:37:43 +0100 Subject: [PATCH 48/49] refactor: simplify gzip size implementation for better coverage --- .../utils/src/getFilesizeGzippedInBytes.ts | 32 +++---------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/packages/utils/src/getFilesizeGzippedInBytes.ts b/packages/utils/src/getFilesizeGzippedInBytes.ts index ae7bb6802..f481c2d09 100644 --- a/packages/utils/src/getFilesizeGzippedInBytes.ts +++ b/packages/utils/src/getFilesizeGzippedInBytes.ts @@ -4,7 +4,7 @@ * MIT Licensed */ -import { createReadStream, existsSync } from "node:fs"; +import { existsSync } from "node:fs"; import { FileOperationError } from "./error.ts"; import { isValidFile } from "./isValidFile.ts"; import { prettyBytes } from "./prettyBytes.ts"; @@ -34,32 +34,10 @@ async function getGzipSize(file: string): Promise { ); } - const { gzipSizeStream } = await import("gzip-size"); - const source = createReadStream(file); - const gzip = gzipSizeStream(); - - return new Promise((resolve, reject) => { - const cleanup = () => { - source.destroy(); - gzip.destroy(); - }; - - source.on("error", (err) => { - cleanup(); - reject(err); - }); - - source - .pipe(gzip) - .on("gzip-size", (size: number) => { - cleanup(); - resolve(size); - }) - .on("error", (err) => { - cleanup(); - reject(err); - }); - }); + const { gzipSize } = await import("gzip-size"); + const { readFile } = await import("node:fs/promises"); + const content = await readFile(file); + return gzipSize(content); } /** From fad512fd7e37b84a712007d09349fa2d68d3709b Mon Sep 17 00:00:00 2001 From: Rodolphe Stoclin Date: Tue, 13 Jan 2026 21:43:53 +0100 Subject: [PATCH 49/49] docs: add removed inputs migration guide for deprecated action --- .github/actions/node-minify/README.md | 46 +++++++++++++++++++-------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/.github/actions/node-minify/README.md b/.github/actions/node-minify/README.md index d65040d9a..0e91ffde8 100644 --- a/.github/actions/node-minify/README.md +++ b/.github/actions/node-minify/README.md @@ -36,6 +36,39 @@ With: compressor: "terser" ``` +### Removed Inputs + +The following inputs are **not supported** in the new action and must be removed from your workflow YAML: + +| Removed Input | Migration Guide | +|---------------|-----------------| +| `include-gzip` | Gzip sizes are now always included in the output. No action needed. | +| `java-version` | Use `actions/setup-java@v4` before running the action (see example below). | + +#### Java Compressors Migration + +If you use `gcc` or `yui` compressors that require Java: + +**Before (deprecated):** +```yaml +- uses: srod/node-minify/.github/actions/node-minify@main + with: + compressor: gcc + java-version: "17" +``` + +**After:** +```yaml +- uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + +- uses: srod/node-minify@v1 + with: + compressor: gcc +``` + See [packages/action/README.md](../../../packages/action/README.md) for full documentation. --- @@ -68,19 +101,6 @@ The following documentation is for the deprecated composite action. | `include-gzip` | Include gzip sizes | No | `true` | | `java-version` | Java version for gcc/yui | No | - | -> **Note about `java-version`:** This input is **not supported** in the new bundled action (`srod/node-minify@v1`). The new action relies on GitHub runners having Java pre-installed. If you need Java compressors (`gcc` or `yui`), use `actions/setup-java` before running the action: -> -> ```yaml -> - uses: actions/setup-java@v4 -> with: -> distribution: 'temurin' -> java-version: '17' -> - uses: srod/node-minify@v1 -> with: -> compressor: gcc -> # ... -> ``` - ### Outputs | Output | Description |