diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..cea1eed --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,232 @@ +name: build + +on: + push: + branches: [master, main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +env: + SOURCE_ARCHIVE: vendor/source/optipng-0.7.8.tar.gz + +jobs: + build: + name: ${{ matrix.id }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - id: darwin-x64 + runner: macos-14 + cross_arch: x86_64 + - id: darwin-arm64 + runner: macos-14 + - id: linux-x64 + runner: ubuntu-latest + - id: linux-x64-musl + runner: ubuntu-latest + musl: true + - id: linux-arm64 + runner: ubuntu-24.04-arm + - id: linux-arm64-musl + runner: ubuntu-24.04-arm + musl: true + - id: win32-x64 + runner: windows-latest + windows: true + - id: win32-arm64 + runner: windows-11-arm + windows: true + msys2_msystem: CLANGARM64 + msys2_install: mingw-w64-clang-aarch64-clang mingw-w64-clang-aarch64-zlib make + + steps: + - uses: actions/checkout@v5 + + - name: Read version + id: version + shell: bash + run: | + v=$(node -p "require('./package.json').version") + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "tag=v$v" >> "$GITHUB_OUTPUT" + + # ── macOS ── + - name: Build (macOS) + if: startsWith(matrix.runner, 'macos') + env: + CROSS_ARCH: ${{ matrix.cross_arch }} + run: | + set -euo pipefail + tmp=$(mktemp -d) + tar -xzf "$SOURCE_ARCHIVE" -C "$tmp" + cd "$tmp"/optipng-* + # optipng has a custom (non-autotools) configure script that does + # NOT understand --host=. CFLAGS/LDFLAGS env vars with -arch + # x86_64 are enough — Apple clang produces x64 binaries and + # Rosetta 2 (preinstalled on macos-14) runs configure's test + # programs. + if [ -n "${CROSS_ARCH:-}" ]; then + export CFLAGS="-arch $CROSS_ARCH" + export LDFLAGS="-arch $CROSS_ARCH" + fi + ./configure --with-system-zlib + make -j"$(sysctl -n hw.ncpu)" + strip src/optipng/optipng + mkdir -p "$GITHUB_WORKSPACE/vendor" + cp src/optipng/optipng "$GITHUB_WORKSPACE/vendor/" + + # ── Linux glibc ── + - name: Build (Linux glibc) + if: startsWith(matrix.runner, 'ubuntu') && !matrix.musl + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -qq -y build-essential zlib1g-dev + tmp=$(mktemp -d) + tar -xzf "$SOURCE_ARCHIVE" -C "$tmp" + cd "$tmp"/optipng-* + ./configure --with-system-zlib + make -j"$(nproc)" + strip src/optipng/optipng + mkdir -p "$GITHUB_WORKSPACE/vendor" + cp src/optipng/optipng "$GITHUB_WORKSPACE/vendor/" + + # ── Linux musl (build + test inside the same alpine container) ── + - name: Build + test (Linux musl via Alpine docker) + if: matrix.musl + run: | + set -euo pipefail + docker run --rm \ + -v "$PWD":/work -w /work \ + node:24-alpine sh -c ' + set -e + apk add --no-cache build-base zlib-dev tar + tmp=$(mktemp -d) + tar -xzf vendor/source/optipng-0.7.8.tar.gz -C "$tmp" + cd "$tmp"/optipng-* + ./configure --with-system-zlib + make -j"$(nproc)" + strip src/optipng/optipng + mkdir -p /work/vendor + cp src/optipng/optipng /work/vendor/ + cd /work + npm install --ignore-scripts + npx ava --timeout=120s + ' + + # ── Windows MSYS2 (x64 mingw OR arm64 clang) ── + - name: Set up MSYS2 + if: matrix.windows + uses: msys2/setup-msys2@v2 + with: + msystem: ${{ matrix.msys2_msystem || 'MINGW64' }} + update: true + install: ${{ matrix.msys2_install || 'mingw-w64-x86_64-gcc mingw-w64-x86_64-zlib make' }} + + - name: Build (Windows MSYS2) + if: matrix.windows + shell: msys2 {0} + run: | + set -euo pipefail + tmp=$(mktemp -d) + tar -xzf vendor/source/optipng-0.7.8.tar.gz -C "$tmp" + cd "$tmp"/optipng-* + # optipng's custom configure doesn't accept `LDFLAGS=...` as a + # positional arg; pass it through the environment instead. + LDFLAGS="-static -static-libgcc" ./configure --with-system-zlib + make -j"$(nproc)" + strip src/optipng/optipng.exe + mkdir -p "$GITHUB_WORKSPACE/vendor" + cp src/optipng/optipng.exe "$GITHUB_WORKSPACE/vendor/" + + - uses: actions/setup-node@v5 + if: ${{ !matrix.musl }} + with: + node-version: '24.15.0' + + - name: Install npm deps (skipping postinstall — binary already at vendor/) + if: ${{ !matrix.musl }} + shell: bash + run: npm install --ignore-scripts + + - name: Test ${{ matrix.id }} binary + if: ${{ !matrix.musl }} + shell: bash + run: | + set -euo pipefail + ls -la vendor/ + npx ava --timeout=120s + + - name: Tarball + shell: bash + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail + mkdir -p artifacts + name="optipng-bin-${TAG}-${{ matrix.id }}.tar.gz" + tar -czf "artifacts/${name}" -C vendor $(ls vendor | grep -E '^optipng(\.exe)?$') + ls -la artifacts/ + + - uses: actions/upload-artifact@v7 + with: + name: bin-${{ matrix.id }} + path: artifacts/*.tar.gz + if-no-files-found: error + retention-days: 30 + + release: + name: auto-release on version bump + needs: build + if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + + - name: Read version from package.json + id: version + run: | + set -euo pipefail + v=$(node -p "require('./package.json').version") + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "tag=v$v" >> "$GITHUB_OUTPUT" + + - name: Check whether tag already exists on origin + id: check_tag + run: | + set -euo pipefail + tag="${{ steps.version.outputs.tag }}" + if git ls-remote --tags origin "refs/tags/$tag" | grep -q .; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag $tag already exists — nothing to release." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Tag $tag does not exist — will release." + fi + + - if: steps.check_tag.outputs.exists == 'false' + uses: actions/download-artifact@v8 + with: + pattern: bin-* + path: artifacts + merge-multiple: true + + - if: steps.check_tag.outputs.exists == 'false' + run: ls -la artifacts/ + + - if: steps.check_tag.outputs.exists == 'false' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + target_commitish: ${{ github.sha }} + name: ${{ steps.version.outputs.tag }} + files: artifacts/*.tar.gz + generate_release_notes: true + fail_on_unmatched_files: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 0647cf4..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: test -on: - - push - - pull_request -jobs: - test: - name: Node.js ${{ matrix.node-version }} on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - node-version: - - 22 - - 20 - - 18 - - 16 - - 14 - os: - - ubuntu-latest - - macos-latest - - windows-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm test diff --git a/.gitignore b/.gitignore index 41f10ff..154b43e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +vendor/* +!vendor/source yarn.lock -vendor/optipng* -vendor/temp \ No newline at end of file +.DS_Store diff --git a/cli.js b/cli.js index 888305a..670edf9 100755 --- a/cli.js +++ b/cli.js @@ -3,7 +3,9 @@ import {spawn} from 'node:child_process'; import process from 'node:process'; import optipng from './index.js'; -const input = process.argv.slice(2); - -spawn(optipng, input, {stdio: 'inherit'}) - .on('exit', process.exit); +const child = spawn(optipng, process.argv.slice(2), {stdio: 'inherit'}); +child.on('exit', code => process.exit(code ?? 0)); +child.on('error', error => { + console.error(error); + process.exit(1); +}); diff --git a/index.js b/index.js index b4ce886..aebc411 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,3 @@ -import lib from './lib/index.js'; +import {BINARY_PATH} from './lib/binary.js'; -export default lib.path(); +export default BINARY_PATH; diff --git a/lib/binary.js b/lib/binary.js new file mode 100644 index 0000000..974e11a --- /dev/null +++ b/lib/binary.js @@ -0,0 +1,12 @@ +import path from 'node:path'; +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; + +export const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url)); +export const VENDOR_DIR = path.join(PACKAGE_ROOT, 'vendor'); + +export function binaryName() { + return process.platform === 'win32' ? 'optipng.exe' : 'optipng'; +} + +export const BINARY_PATH = path.join(VENDOR_DIR, binaryName()); diff --git a/lib/filename.js b/lib/filename.js deleted file mode 100644 index 1b122bb..0000000 --- a/lib/filename.js +++ /dev/null @@ -1,16 +0,0 @@ -import process from 'node:process'; - -const FILENAME_LIST = { - darwin: 'optipng.macho', - linux: 'optipng.elf', - win32: 'optipng.exe', -}; - -export const getFilename = () => { - const filename = FILENAME_LIST[process.platform]; - if (!filename) { - throw new Error('Unsupported platform'); - } - - return filename; -}; diff --git a/lib/index.js b/lib/index.js deleted file mode 100644 index 1664672..0000000 --- a/lib/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import fs from 'node:fs'; -import {fileURLToPath} from 'node:url'; -import { getFilename } from './filename.js'; -import BinWrapper from '@xhmikosr/bin-wrapper'; - -const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url))); -const url = `https://raw.githubusercontent.com/PruvoNet/optipng-bin/v${pkg.version}/vendor/`; - -const binWrapper = new BinWrapper() - .src(`${url}macos/arm64/optipng.macho`, 'darwin', 'arm64') - .src(`${url}macos/x64/optipng.macho`, 'darwin', 'x64') - .src(`${url}linux/arm64/optipng.elf`, 'linux', 'arm64') - .src(`${url}linux/x64/optipng.elf`, 'linux', 'x64') - .src(`${url}win/x64/optipng.exe`, 'win32', 'x64') - .dest(fileURLToPath(new URL('../vendor', import.meta.url))) - .use(getFilename()); - -export default binWrapper; diff --git a/lib/install.js b/lib/install.js index c98fd91..29d0bbe 100644 --- a/lib/install.js +++ b/lib/install.js @@ -1,32 +1,133 @@ +import fs from 'node:fs'; +import path from 'node:path'; import process from 'node:process'; -import {fileURLToPath} from 'node:url'; +import {Readable} from 'node:stream'; +import {pipeline} from 'node:stream/promises'; +import {spawnSync, execSync} from 'node:child_process'; import binBuild from 'bin-build'; -import bin from './index.js'; -import fs from 'node:fs'; -(async () => { +import {BINARY_PATH, VENDOR_DIR, PACKAGE_ROOT, binaryName} from './binary.js'; + +const pkg = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')); + +function detectLibc() { + if (process.platform !== 'linux') { + return null; + } + try { - await bin.run(['--version']); - console.log('optipng pre-build test passed successfully'); - } catch (error) { - console.warn(error.message); - console.warn('optipng pre-build test failed'); - console.info('compiling from source'); + const out = execSync('getconf GNU_LIBC_VERSION 2>&1 || ldd --version 2>&1 || true', {encoding: 'utf8'}); + if (/musl/i.test(out)) { + return 'musl'; + } + } catch { + // fallthrough to glibc + } + + return 'glibc'; +} +function tarballName() { + const libc = detectLibc(); + const suffix = libc === 'musl' ? '-musl' : ''; + return `${pkg.name}-v${pkg.version}-${process.platform}-${process.arch}${suffix}.tar.gz`; +} + +function tarballUrl() { + const {host, remote_path: remotePath} = pkg.binary; + const remote = remotePath.replace('{version}', pkg.version); + return `${host.replace(/\/+$/, '')}/${remote.replace(/^\/+/, '')}/${tarballName()}`; +} + +function binaryRuns() { + if (!fs.existsSync(BINARY_PATH)) { + return false; + } + + const result = spawnSync(BINARY_PATH, ['--version'], {stdio: 'ignore'}); + return result.status === 0; +} + +async function downloadPrebuilt() { + const url = tarballUrl(); + console.log(`${pkg.name}: downloading prebuilt from ${url}`); + const response = await fetch(url, {redirect: 'follow'}); + if (!response.ok || !response.body) { + throw new Error(`HTTP ${response.status} ${response.statusText} from ${url}`); + } + + fs.mkdirSync(VENDOR_DIR, {recursive: true}); + const tempPath = path.join(VENDOR_DIR, `_dl.${process.pid}.tgz`); + try { + await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(tempPath)); + const tar = spawnSync('tar', ['-xzf', tempPath, '-C', VENDOR_DIR], {stdio: 'inherit'}); + if (tar.status !== 0) { + throw new Error(`tar extraction exited with status ${tar.status}`); + } + } finally { try { - const source = fileURLToPath(new URL('../vendor/source/optipng-0.7.8.tar.gz', import.meta.url)); - // From https://sourceforge.net/projects/optipng/files/OptiPNG/ - await binBuild.file(source, [ - `./configure --with-system-zlib --prefix="${bin.dest()}/temp" --bindir="${bin.dest()}/temp"`, - 'make install', - ]); - - fs.renameSync(`${bin.dest()}/temp/optipng`, bin.path()); - console.log('optipng built successfully'); - } catch (error) { - console.error(error.stack); - - // eslint-disable-next-line unicorn/no-process-exit + fs.unlinkSync(tempPath); + } catch {} + } + + if (!fs.existsSync(BINARY_PATH)) { + throw new Error(`tarball did not contain ${binaryName()}; expected at ${BINARY_PATH}`); + } + + if (process.platform !== 'win32') { + fs.chmodSync(BINARY_PATH, 0o755); + } +} + +async function buildFromSource() { + console.log(`${pkg.name}: compiling from source`); + const source = path.join(PACKAGE_ROOT, 'vendor', 'source', 'optipng-0.7.8.tar.gz'); + const tempDir = path.join(VENDOR_DIR, 'temp'); + const config = [ + `./configure --with-system-zlib`, + `--prefix="${tempDir}" --bindir="${tempDir}"`, + ].join(' '); + + await binBuild.file(source, [ + config, + 'make install', + ]); + + fs.mkdirSync(VENDOR_DIR, {recursive: true}); + fs.renameSync(path.join(tempDir, binaryName()), BINARY_PATH); +} + +async function main() { + if (binaryRuns()) { + console.log(`${pkg.name}: binary already installed at ${BINARY_PATH}`); + return; + } + + try { + await downloadPrebuilt(); + if (binaryRuns()) { + console.log(`${pkg.name}: prebuilt installed at ${BINARY_PATH}`); + return; + } + + throw new Error('downloaded binary does not run on this platform'); + } catch (error) { + console.warn(`${pkg.name}: prebuild download failed: ${error.message}`); + try { + await buildFromSource(); + if (binaryRuns()) { + console.log(`${pkg.name}: source build successful at ${BINARY_PATH}`); + return; + } + + throw new Error('source build produced a binary that does not run'); + } catch (error2) { + console.error(`${pkg.name}: source build also failed: ${error2.stack || error2.message}`); process.exit(1); } } -})(); +} + +main().catch(error => { + console.error(`${pkg.name}: install failed: ${error.stack || error.message}`); + process.exit(1); +}); diff --git a/package.json b/package.json index 842a3cf..b63defc 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,24 @@ { "name": "optipng-bin", - "version": "9.1.1", + "version": "9.2.0", "description": "OptiPNG wrapper that makes it seamlessly available as a local dependency", "license": "MIT", - "repository": "imagemin/optipng-bin", + "repository": "PruvoNet/optipng-bin", "type": "module", "exports": "./index.js", "bin": { "optipng": "cli.js" }, "engines": { - "node": "^14.13.1 || >=16.0.0 || >= 18.0.0 || >= 20.0.0 || >= 22.0.0" + "node": ">=18" + }, + "binary": { + "host": "https://github.com", + "remote_path": "PruvoNet/optipng-bin/releases/download/v{version}" }, "scripts": { "postinstall": "node lib/install.js", - "test": "xo && ava --timeout=120s" + "test": "ava --timeout=120s" }, "files": [ "index.js", @@ -32,15 +36,10 @@ "optipng" ], "dependencies": { - "@xhmikosr/bin-wrapper": "^13.0.5", "bin-build": "^3.0.0" }, "devDependencies": { - "@xhmikosr/bin-check": "^7.0.3", - "ava": "^4.2.0", - "compare-size": "^3.0.0", - "execa": "^6.1.0", - "tempy": "^3.0.0", - "xo": "^0.48.0" + "ava": "^6.0.0", + "tempy": "^3.0.0" } } diff --git a/test/test.js b/test/test.js index dc665fb..6285347 100644 --- a/test/test.js +++ b/test/test.js @@ -1,52 +1,26 @@ import fs from 'node:fs'; import path from 'node:path'; -import process from 'node:process'; +import {spawnSync} from 'node:child_process'; import {fileURLToPath} from 'node:url'; import test from 'ava'; -import {execa} from 'execa'; import {temporaryDirectory} from 'tempy'; -import binCheck from '@xhmikosr/bin-check'; -import binBuild from 'bin-build'; -import compareSize from 'compare-size'; import optipng from '../index.js'; -test('rebuild the optipng binaries', async t => { - // Skip the test on Windows - if (process.platform === 'win32') { - t.pass(); - return; - } - - const temporary = temporaryDirectory(); - const source = fileURLToPath(new URL('../vendor/source/optipng-0.7.8.tar.gz', import.meta.url)); - - await binBuild.file(source, [ - `./configure --with-system-zlib --prefix="${temporary}" --bindir="${temporary}"`, - 'make install', - ]); - - t.true(fs.existsSync(path.join(temporary, 'optipng'))); -}); - -test('return path to binary and verify that it is working', async t => { - t.true(await binCheck(optipng, ['--version'])); +test('binary exists and reports a version', t => { + const result = spawnSync(optipng, ['--version'], {encoding: 'utf8'}); + t.is(result.status, 0); + t.regex(result.stdout, /OptiPNG/i); }); -test('minify a PNG', async t => { - const temporary = temporaryDirectory(); - const sourcePath = fileURLToPath(new URL('fixtures/test.png', import.meta.url)); - const destinationPath = path.join(temporary, 'test.png'); - const arguments_ = [ - '-strip', - 'all', - '-clobber', - '-out', - destinationPath, - sourcePath, - ]; +test('minifies a png', t => { + const tmp = temporaryDirectory(); + const src = fileURLToPath(new URL('fixtures/test.png', import.meta.url)); + const dst = path.join(tmp, 'test.png'); - await execa(optipng, arguments_); - const result = await compareSize(sourcePath, destinationPath); + const result = spawnSync(optipng, ['-strip', 'all', '-clobber', '-out', dst, src]); + t.is(result.status, 0); - t.true(result[destinationPath] < result[sourcePath]); + const srcSize = fs.statSync(src).size; + const dstSize = fs.statSync(dst).size; + t.true(dstSize < srcSize, `expected ${dstSize} < ${srcSize}`); }); diff --git a/vendor/linux/arm64/optipng.elf b/vendor/linux/arm64/optipng.elf deleted file mode 100755 index 2558e9a..0000000 Binary files a/vendor/linux/arm64/optipng.elf and /dev/null differ diff --git a/vendor/linux/x64/optipng.elf b/vendor/linux/x64/optipng.elf deleted file mode 100755 index 49d2d9a..0000000 Binary files a/vendor/linux/x64/optipng.elf and /dev/null differ diff --git a/vendor/macos/arm64/optipng.macho b/vendor/macos/arm64/optipng.macho deleted file mode 100755 index 2533a95..0000000 Binary files a/vendor/macos/arm64/optipng.macho and /dev/null differ diff --git a/vendor/macos/x64/optipng.macho b/vendor/macos/x64/optipng.macho deleted file mode 100644 index d603c50..0000000 Binary files a/vendor/macos/x64/optipng.macho and /dev/null differ diff --git a/vendor/win/x64/optipng.exe b/vendor/win/x64/optipng.exe deleted file mode 100644 index 76920ba..0000000 Binary files a/vendor/win/x64/optipng.exe and /dev/null differ