From 5612a720e3204bd5a7bf757f204800b112528c25 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:16:00 +0200 Subject: [PATCH 1/6] feat: add local OpenCompress CLI preview --- bin/opencompress.mjs | 344 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 bin/opencompress.mjs diff --git a/bin/opencompress.mjs b/bin/opencompress.mjs new file mode 100644 index 0000000..b871941 --- /dev/null +++ b/bin/opencompress.mjs @@ -0,0 +1,344 @@ +#!/usr/bin/env node + +import { copyFile, mkdir, readdir, stat, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import sharp from 'sharp'; + +const VERSION = '2.1.0'; +const SUPPORTED_INPUTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.tif', '.tiff', '.bmp']); +const FORMAT_ALIASES = new Map([ + ['jpg', 'jpeg'], + ['jpeg', 'jpeg'], + ['png', 'png'], + ['webp', 'webp'] +]); +const FORMAT_EXTENSION = { + jpeg: '.jpg', + png: '.png', + webp: '.webp' +}; + +main().catch((error) => { + console.error(`[opencompress-cli] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); + +async function main() { + const options = parseArgs(process.argv.slice(2)); + + if (options.help) { + printHelp(); + return; + } + + if (options.version) { + console.log(VERSION); + return; + } + + if (!options.inputs.length) { + throw new Error('No input files or directories supplied. Run with --help for usage.'); + } + + const outputDir = path.resolve(options.output); + const files = await collectInputFiles(options.inputs, options.recursive, outputDir); + if (!files.length) { + throw new Error('No supported images found. Supported inputs: JPG, PNG, WebP, TIFF and BMP.'); + } + + await mkdir(outputDir, { recursive: true }); + + const results = []; + for (const filePath of files) { + try { + const result = await processImage(filePath, outputDir, options); + results.push(result); + if (!options.json) printResult(result); + } catch (error) { + const failure = { + input: filePath, + status: 'failed', + error: error instanceof Error ? error.message : String(error) + }; + results.push(failure); + if (!options.json) console.error(`FAILED ${filePath}: ${failure.error}`); + } + } + + const successful = results.filter((item) => item.status !== 'failed'); + const failed = results.length - successful.length; + const totalInputBytes = successful.reduce((sum, item) => sum + item.inputBytes, 0); + const totalOutputBytes = successful.reduce((sum, item) => sum + item.outputBytes, 0); + const savedBytes = totalInputBytes - totalOutputBytes; + const savedPercent = totalInputBytes > 0 ? roundOne((savedBytes / totalInputBytes) * 100) : 0; + + const summary = { + version: VERSION, + outputDir, + processed: successful.length, + failed, + totalInputBytes, + totalOutputBytes, + savedBytes, + savedPercent, + results + }; + + if (options.json) { + console.log(JSON.stringify(summary, null, 2)); + } else { + console.log(''); + console.log(`Processed ${successful.length}/${results.length} image(s).`); + console.log(`Total: ${formatBytes(totalInputBytes)} -> ${formatBytes(totalOutputBytes)} (${formatSavings(savedPercent)}).`); + console.log(`Output: ${outputDir}`); + } + + if (failed > 0) process.exitCode = 1; +} + +function parseArgs(argv) { + const options = { + inputs: [], + output: 'opencompress-output', + format: 'webp', + quality: 82, + maxWidth: null, + maxHeight: null, + recursive: false, + keepOriginalIfLarger: true, + background: '#ffffff', + json: false, + help: false, + version: false + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg.startsWith('-')) { + options.inputs.push(arg); + continue; + } + + switch (arg) { + case '-h': + case '--help': + options.help = true; + break; + case '-v': + case '--version': + options.version = true; + break; + case '-o': + case '--output': + options.output = readValue(argv, ++index, arg); + break; + case '-f': + case '--format': { + const raw = readValue(argv, ++index, arg).toLowerCase(); + const format = FORMAT_ALIASES.get(raw); + if (!format) throw new Error(`Unsupported format "${raw}". Use webp, jpg/jpeg or png.`); + options.format = format; + break; + } + case '-q': + case '--quality': + options.quality = readInteger(argv, ++index, arg, 1, 100); + break; + case '--max-width': + options.maxWidth = readInteger(argv, ++index, arg, 1, 20000); + break; + case '--max-height': + options.maxHeight = readInteger(argv, ++index, arg, 1, 20000); + break; + case '-r': + case '--recursive': + options.recursive = true; + break; + case '--allow-larger': + options.keepOriginalIfLarger = false; + break; + case '--background': { + const value = readValue(argv, ++index, arg); + if (!/^#[0-9a-fA-F]{6}$/.test(value)) { + throw new Error('--background must be a six-digit hex color such as #ffffff.'); + } + options.background = value; + break; + } + case '--json': + options.json = true; + break; + default: + throw new Error(`Unknown option "${arg}". Run with --help for usage.`); + } + } + + return options; +} + +function readValue(argv, index, flag) { + const value = argv[index]; + if (!value || value.startsWith('-')) throw new Error(`${flag} requires a value.`); + return value; +} + +function readInteger(argv, index, flag, min, max) { + const raw = readValue(argv, index, flag); + const value = Number(raw); + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error(`${flag} must be an integer between ${min} and ${max}.`); + } + return value; +} + +async function collectInputFiles(inputs, recursive, outputDir) { + const collected = new Set(); + + for (const input of inputs) { + const resolved = path.resolve(input); + let info; + try { + info = await stat(resolved); + } catch { + throw new Error(`Input does not exist: ${input}`); + } + + if (info.isFile()) { + if (!isSupportedInput(resolved)) throw new Error(`Unsupported image type: ${input}`); + collected.add(resolved); + continue; + } + + if (!info.isDirectory()) throw new Error(`Input is not a file or directory: ${input}`); + await walkDirectory(resolved, recursive, outputDir, collected); + } + + return [...collected].sort((a, b) => a.localeCompare(b)); +} + +async function walkDirectory(directory, recursive, outputDir, collected) { + if (samePath(directory, outputDir)) return; + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isFile() && isSupportedInput(fullPath)) { + collected.add(fullPath); + } else if (recursive && entry.isDirectory() && !samePath(fullPath, outputDir)) { + await walkDirectory(fullPath, true, outputDir, collected); + } + } +} + +function samePath(a, b) { + return path.resolve(a) === path.resolve(b); +} + +function isSupportedInput(filePath) { + return SUPPORTED_INPUTS.has(path.extname(filePath).toLowerCase()); +} + +async function processImage(inputPath, outputDir, options) { + const inputInfo = await stat(inputPath); + const metadata = await sharp(inputPath).metadata(); + + let pipeline = sharp(inputPath).rotate(); + if (options.maxWidth || options.maxHeight) { + pipeline = pipeline.resize({ + width: options.maxWidth ?? undefined, + height: options.maxHeight ?? undefined, + fit: 'inside', + withoutEnlargement: true + }); + } + + if (options.format === 'jpeg') { + if (metadata.hasAlpha) pipeline = pipeline.flatten({ background: options.background }); + pipeline = pipeline.jpeg({ quality: options.quality, mozjpeg: true }); + } else if (options.format === 'png') { + pipeline = pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }); + } else { + pipeline = pipeline.webp({ quality: options.quality, effort: 4 }); + } + + const outputBuffer = await pipeline.toBuffer(); + const inputBytes = inputInfo.size; + const stem = path.parse(inputPath).name; + + if (options.keepOriginalIfLarger && outputBuffer.length > inputBytes) { + const outputPath = uniqueOutputPath(outputDir, stem, path.extname(inputPath).toLowerCase()); + await copyFile(inputPath, outputPath); + return { + input: inputPath, + output: outputPath, + status: 'original-kept', + format: normalizeFormat(metadata.format), + inputBytes, + outputBytes: inputBytes, + savedBytes: 0, + savedPercent: 0 + }; + } + + const extension = FORMAT_EXTENSION[options.format]; + const outputPath = uniqueOutputPath(outputDir, stem, extension); + await writeFile(outputPath, outputBuffer); + + const savedBytes = inputBytes - outputBuffer.length; + const savedPercent = inputBytes > 0 ? roundOne((savedBytes / inputBytes) * 100) : 0; + return { + input: inputPath, + output: outputPath, + status: 'optimized', + format: options.format, + quality: options.format === 'png' ? null : options.quality, + inputBytes, + outputBytes: outputBuffer.length, + savedBytes, + savedPercent + }; +} + +function uniqueOutputPath(outputDir, stem, extension) { + let counter = 1; + let candidate = path.join(outputDir, `${stem}${extension}`); + while (existsSync(candidate)) { + counter += 1; + candidate = path.join(outputDir, `${stem}-${counter}${extension}`); + } + return candidate; +} + +function normalizeFormat(value) { + if (value === 'jpg') return 'jpeg'; + return value || 'original'; +} + +function printResult(result) { + const label = result.status === 'original-kept' ? 'KEPT' : 'OK'; + console.log(`${label} ${path.basename(result.input)} -> ${path.basename(result.output)} | ${formatBytes(result.inputBytes)} -> ${formatBytes(result.outputBytes)} | ${formatSavings(result.savedPercent)}`); +} + +function formatSavings(percent) { + if (percent > 0) return `${percent}% smaller`; + if (percent < 0) return `${Math.abs(percent)}% larger`; + return 'same size'; +} + +function formatBytes(bytes) { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / 1024 ** exponent; + return `${value >= 10 || exponent === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[exponent]}`; +} + +function roundOne(value) { + return Math.round(value * 10) / 10; +} + +function printHelp() { + console.log(`OpenCompress CLI ${VERSION} (preview)\n\nUsage:\n npm run cli -- [options]\n node bin/opencompress.mjs [options]\n\nOptions:\n -o, --output Output directory (default: opencompress-output)\n -f, --format webp, jpg/jpeg or png (default: webp)\n -q, --quality <1-100> Lossy output quality (default: 82)\n --max-width Resize to fit within this width\n --max-height Resize to fit within this height\n -r, --recursive Scan nested directories\n --allow-larger Keep optimized output even when it is larger\n --background JPG alpha background (default: #ffffff)\n --json Print machine-readable JSON summary\n -v, --version Print version\n -h, --help Show this help\n\nSupported inputs:\n JPG, PNG, WebP, TIFF and BMP\n\nExamples:\n npm run cli -- ./images --format webp --quality 82 --max-width 1600\n npm run cli -- ./catalog --recursive --format jpg --quality 88 -o ./optimized\n npm run cli -- hero.png --format webp --json\n\nThe CLI preview is local-only. It does not call reSmush.it or any other external image service.`); +} From 858571fa48b36c013b1ad4c339f19f02ad684b57 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:16:08 +0200 Subject: [PATCH 2/6] chore: expose CLI npm script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index e12fbbf..12b77d3 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "node server/index.mjs", "start": "node server/index.mjs --production", "preview": "node server/index.mjs --production", + "cli": "node bin/opencompress.mjs", "build": "npm run typecheck && vite build", "typecheck": "tsc --noEmit", "typecheck:strict": "tsc --noEmit --noUnusedLocals --noUnusedParameters", From c5304792f4632f88a3b0bfd20b0d0a38bba14b3c Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:16:16 +0200 Subject: [PATCH 3/6] ci: smoke-test CLI image processing --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3d256c..bea2100 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,14 @@ jobs: - name: Check server syntax run: node --check server/index.mjs + - name: Smoke-test CLI + run: | + node --check bin/opencompress.mjs + npm run cli -- --help + node --input-type=module -e "import sharp from 'sharp'; await sharp({ create: { width: 32, height: 32, channels: 4, background: '#ff0000' } }).png().toFile('/tmp/opencompress-ci.png');" + npm run cli -- /tmp/opencompress-ci.png --format webp --output /tmp/opencompress-cli-output + test -s /tmp/opencompress-cli-output/opencompress-ci.webp + - name: Typecheck strict run: npm run typecheck:strict From 0e059899646f288b8675e76311974f8c1cb75ae9 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:16:52 +0200 Subject: [PATCH 4/6] docs: add CLI quick start and tool comparison --- README.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3a2cdb8..3bc8ead 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,18 @@ Most image compressors are either single-file tools, cloud services or too gener - **Safe defaults:** metadata removal and keep-original-if-larger are enabled by design. - **Optional external comparison:** reSmush.it can be enabled explicitly and automatically falls back to local compression when possible. +## Where it fits + +This is a workflow comparison, not a claim that one encoder always produces smaller files. Results depend on image content, codec and settings. + +| Tool | Processing model | Strong fit | OpenCompress difference | +| --- | --- | --- | --- | +| **OpenCompress Studio** | Local Node.js + Sharp by default; optional reSmush.it | Repeatable shop and creator batches | Presets, SEO rename, target-size mode, before/after review and ZIP batch export in one workflow | +| **[Squoosh](https://squoosh.app/)** | Local in the browser | Hands-on visual codec tuning | OpenCompress focuses on repeatable multi-image shop workflows and can also be automated from the CLI | +| **[TinyPNG / TinyJPG](https://tinypng.com/)** | Hosted service/API; images are sent to the service for compression | Managed web/API compression | OpenCompress can keep the complete image workflow local with no account or API key | + +Squoosh documents that image processing stays on-device. TinyPNG's API documentation describes uploading image data to its service for compression. OpenCompress is aimed at users who want the local model plus batch-oriented e-commerce tooling. + ## Quick start ### Windows @@ -49,12 +61,47 @@ npm run dev Open `http://127.0.0.1:5174`. +## CLI preview + +OpenCompress 2.1 also includes a local-only CLI foundation for scripts, CI jobs and large folders. It intentionally does not call reSmush.it or another external image service. + +```bash +# Convert a folder to WebP at quality 82 +npm run cli -- ./images --format webp --quality 82 + +# Resize a complete catalog recursively +npm run cli -- ./catalog --recursive --format webp --quality 82 --max-width 1600 --max-height 1600 -o ./optimized + +# JPEG output with a white background for transparent inputs +npm run cli -- hero.png --format jpg --quality 90 --background '#ffffff' + +# Machine-readable result summary +npm run cli -- ./images --format webp --json +``` + +CLI options: + +```text +-o, --output Output directory +-f, --format webp, jpg/jpeg or png +-q, --quality <1-100> Lossy output quality + --max-width Maximum output width + --max-height Maximum output height +-r, --recursive Scan nested directories + --allow-larger Keep optimized output even when larger + --background JPG alpha background + --json Machine-readable JSON summary +``` + +Run `npm run cli -- --help` for the complete usage text. The CLI currently supports JPG, PNG, WebP, TIFF and BMP inputs. Auto Best, target-size search and reSmush.it remain GUI-only for now. + ## Feature highlights - Batch upload for up to 250 images -- JPG, PNG, WebP, GIF, TIF and BMP input +- JPG, PNG, WebP, GIF, TIF and BMP input in the GUI - JPG, PNG and WebP output - Local compression with `sharp` +- Local-only CLI preview for scripts and folder processing - Auto Best local mode that tests multiple output candidates and keeps the smallest - Optional reSmush.it API compression - Auto Compare local vs reSmush.it @@ -80,8 +127,9 @@ Open `http://127.0.0.1:5174`. | **Auto Best local** | Yes | No | Smallest local result without cloud uploads | | **reSmush.it API** | No | Yes | Explicit external compression | | **Auto Compare** | No | Yes | Comparing local output with reSmush.it | +| **CLI preview** | Yes | No | Scripts, CI jobs and local folders | -Local-only processing stores temporary job files in `.opencompress/` and removes old jobs automatically after the configured TTL. +Local-only processing stores temporary GUI job files in `.opencompress/` and removes old jobs automatically after the configured TTL. CLI output is written directly to the selected output directory. > If you bind `OPENCOMPRESS_HOST` to a non-loopback address, the local API becomes reachable from your network. Keep the default `127.0.0.1` unless you intentionally want remote access. @@ -134,7 +182,7 @@ If the target cannot be reached, the result is still returned with a warning ins ## reSmush.it support -reSmush.it is optional and is never used by Local only or Auto Best local mode. +reSmush.it is optional and is never used by Local only, Auto Best local or CLI mode. Supported external inputs: @@ -166,6 +214,7 @@ Open `http://127.0.0.1:5174`. ```bash npm run dev # Local development server with Vite middleware npm start # Production server using dist/ +npm run cli -- --help # Local CLI usage npm run build # Type-check and build production assets npm run typecheck # TypeScript checks npm run typecheck:strict # TypeScript checks including unused-code detection @@ -190,6 +239,7 @@ OPENCOMPRESS_USER_AGENT=OpenCompress-Studio/2.1.0 ```text OpenCompress/ +├─ bin/ Local CLI preview ├─ src/ React UI ├─ server/ Local Express + Sharp processing API ├─ docs/ Repository media and documentation @@ -203,6 +253,7 @@ OpenCompress/ Good next contributions include: +- CLI parity for Auto Best and target-size mode - Real per-file streaming progress - Drag-and-drop file sorting - AVIF export From 63562a1eea3f0887df619a2f92ff2a480460eeb3 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:17:30 +0200 Subject: [PATCH 5/6] docs: finalize v2.1.0 release notes --- RELEASE_NOTES.md | 67 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index fc3ac34..43b5803 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,17 @@ # OpenCompress Studio 2.1.0 +OpenCompress Studio 2.1.0 turns the project into a more complete local-first batch image workflow for shops, creators and automation. + +## Highlights + +- Private-by-default local compression with Sharp. +- Batch workflow for up to 250 images in the GUI. +- Auto Best local mode for comparing multiple local output candidates. +- Target file-size mode for JPG/WebP output. +- Before/after preview, candidate comparison and detailed savings. +- Shop-focused presets, SEO batch rename and ZIP export. +- New local-only CLI preview for scripts, CI jobs and large folders. + ## Added - Auto Best local mode that tests multiple local output candidates and keeps the smallest result. @@ -13,16 +25,57 @@ - Candidate comparison panel for Auto Best and Auto Compare results. - Expanded results table with format, dimensions, quality, method, status and warnings. - More specific shop/image presets. +- Local CLI preview with file/folder input, recursive scanning, WebP/JPG/PNG output, quality, resize, JSON summaries and keep-original-if-larger behavior. +- GitHub contribution, security, issue and pull-request templates. +- Dependabot configuration and stronger CI checks. + +## Fixed and hardened + +- Fresh Windows clones now build before starting production mode. +- Invalid job IDs can no longer collapse to the jobs root during deletion. +- Target-size compression now works when preferred quality is below 35. +- Duplicate filenames map to the correct active preview item. +- Upload count and malformed settings errors return clearer API responses. +- Result downloads use streaming file responses instead of unnecessary whole-file buffering. +- reSmush.it requests now have bounded timeouts and HTTPS result validation. +- Numeric environment settings are validated and bounded. +- CLI image processing is smoke-tested in GitHub Actions with a generated image fixture. ## Changed -- Local compression now reports original and output dimensions/formats. +- Local compression reports original and output dimensions/formats. - Auto Compare can run in a fair mode with no resize, original format and matching quality. -- ZIP report now includes settings and detailed result metadata. -- README updated for the V2.1 workflow. +- ZIP report includes settings and detailed result metadata. +- README now explains privacy modes, CLI usage and workflow positioning versus Squoosh and TinyPNG/TinyJPG without synthetic benchmark claims. +- Supported Node.js versions are aligned across documentation, launcher and CI. + +## CLI preview + +```bash +npm ci +npm run cli -- ./images --format webp --quality 82 --max-width 1600 -o ./optimized +``` + +Run `npm run cli -- --help` for all options. The CLI is local-only in 2.1.0; Auto Best, target-size search and reSmush.it remain GUI-only. + +## Privacy notes + +- Local only, Auto Best local and CLI modes do not upload images externally. +- reSmush.it remains optional and is used only when explicitly selected in the GUI. +- Temporary GUI jobs live under `.opencompress/` and expire automatically. + +## Requirements + +- Node.js 20.19+ or 22.12+ +- npm 10+ + +## Install -## Notes +```bash +git clone https://github.com/SLP-DEV1/OpenCompress.git +cd OpenCompress +npm ci +npm run dev +``` -- reSmush.it remains optional and uploads selected files to an external API only when explicitly selected. -- Local only and Auto Best local modes do not upload images externally. -- `node_modules/`, `dist/` and `.opencompress/` are excluded from the release package. +Windows users can also run `start.bat` after installing a supported Node.js version. From c7b63f5766f9e68677e9734ef3a6e687f7ad5563 Mon Sep 17 00:00:00 2001 From: SLP-DEV1 Date: Thu, 27 Aug 2026 01:17:40 +0200 Subject: [PATCH 6/6] ci: add reproducible release workflow --- .github/workflows/release.yml | 78 +++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..08a5ba5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: Release + +on: + push: + branches: + - 'release/v*' + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.19.0 + cache: npm + + - name: Validate release version + shell: bash + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME#release/}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid release branch tag: $TAG" >&2 + exit 1 + fi + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + if [[ "v${PACKAGE_VERSION}" != "$TAG" ]]; then + echo "package.json version v${PACKAGE_VERSION} does not match $TAG" >&2 + exit 1 + fi + echo "TAG=$TAG" >> "$GITHUB_ENV" + echo "ARCHIVE=opencompress-${PACKAGE_VERSION}-source.zip" >> "$GITHUB_ENV" + + - name: Install dependencies + run: npm ci + + - name: Validate source + run: | + node --check server/index.mjs + node --check bin/opencompress.mjs + npm run typecheck:strict + npm run build + + - name: Build source archive + shell: bash + run: | + set -euo pipefail + git archive --format=zip --output="$ARCHIVE" HEAD + sha256sum "$ARCHIVE" > "$ARCHIVE.sha256" + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists; uploading assets only." + else + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --title "OpenCompress Studio $TAG" \ + --notes-file RELEASE_NOTES.md + fi + gh release upload "$TAG" "$ARCHIVE" "$ARCHIVE.sha256" --clobber