Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Every push to main ships: build the NSIS installer and publish it as a GitHub
# Release named after package.json's version, with generated notes from the
# merged PRs. Existing versions are immutable: a release requires a NEW version
# in package.json (bump it inside the PR), and CI refuses to overwrite one.
#
# The bundled tools (ImageMagick, CaesiumCLT, 7-Zip, ffmpeg, mutool,
# LibreOffice, Ghostscript, Real-ESRGAN) are not in git, and a runner has none
# of the local installs fetch-binaries copies from. `--pinned` stages the same
# versions from official downloads, each checked against a pinned SHA-256
# (scripts/pinned-tools.mjs), and verify-bundle fails the build if any tool is
# missing or does not run, both before and after electron-builder packs it.
#
# Pull requests that touch the release machinery, and manual dispatches, run a
# DRY RUN: everything up to and including the verified installer, uploaded as a
# workflow artifact, but never published.
#
# Gates: typecheck, lint, prettier, unit tests. The e2e suite stays the local
# pre-PR gate (it launches the built app with real tools, CLAUDE.md).
#
# Unsigned: no certificate is configured.
name: release

on:
push:
branches: [main]
# Docs and licence text cannot change the installer. Without this a docs
# push would fail on "Require a new version", or, bumped, publish an
# identical installer under a new name for nothing.
paths-ignore:
- '**.md'
- 'docs/**'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
pull_request:
paths:
- '.github/workflows/release.yml'
- 'scripts/fetch-binaries.mjs'
- 'scripts/pinned-tools.mjs'
- 'scripts/verify-bundle.mjs'
- 'electron-builder.yml'
- 'package.json'
workflow_dispatch:

concurrency:
# Releases from main queue rather than race; a PR's newer push replaces its
# older dry run.
group: release-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
release:
runs-on: windows-latest
timeout-minutes: 75
permissions:
contents: write
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npx prettier --check .
- run: npm test

- name: Read version
id: ver
shell: pwsh
run: |
$v = (Get-Content package.json -Raw | ConvertFrom-Json).version
if (-not $v) { throw 'could not read version from package.json' }
"version=$v" >> $env:GITHUB_OUTPUT

# Runs on dry runs too, so a PR that forgot the bump fails here, not
# after it is merged.
- name: Require a new version
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
# Actions runs pwsh with $ErrorActionPreference='Stop', and PowerShell
# 7.4 can turn a non-zero NATIVE exit into a throw. `gh release view`
# exits 1 when the tag does not exist, which is the expected path, so
# the exit code is checked by hand.
$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false
$v = '${{ steps.ver.outputs.version }}'
gh release view "v$v" *> $null
if ($LASTEXITCODE -eq 0) { throw "Release v$v already exists. Bump package.json in the PR." }
Write-Host "v$v is new"
$global:LASTEXITCODE = 0

- name: Fetch pinned tools
run: node scripts/fetch-binaries.mjs --pinned

- name: Verify bundled tools (resources/)
run: node scripts/verify-bundle.mjs resources --manifest dist/resources-manifest.txt

# --publish never: on CI electron-builder tries to publish ITSELF and
# fails wanting a token; the Publish step below owns publishing.
- run: npm run build
- run: npx electron-builder --win --publish never

# electron-builder only WARNS when an extraResources source is missing, so
# check what actually got packed.
- name: Verify bundled tools (packed app)
run: node scripts/verify-bundle.mjs dist/win-unpacked/resources --manifest dist/packed-manifest.txt

- name: Check installer
id: exe
shell: pwsh
run: |
$v = '${{ steps.ver.outputs.version }}'
$exe = "dist/Filesmith-Setup-x64-$v.exe"
if (-not (Test-Path $exe)) { throw "installer not found: $exe" }
$item = Get-Item $exe
$sha = (Get-FileHash $exe -Algorithm SHA256).Hash
$mb = [math]::Round($item.Length / 1MB, 1)
"path=$exe" >> $env:GITHUB_OUTPUT
"## Filesmith $v installer" >> $env:GITHUB_STEP_SUMMARY
"" >> $env:GITHUB_STEP_SUMMARY
"- ``$($item.Name)``: $mb MB ($($item.Length) bytes)" >> $env:GITHUB_STEP_SUMMARY
"- sha256 ``$sha``" >> $env:GITHUB_STEP_SUMMARY
"- unsigned (no certificate configured)" >> $env:GITHUB_STEP_SUMMARY
"- event ``${{ github.event_name }}``" >> $env:GITHUB_STEP_SUMMARY

- name: Upload dry-run installer
if: github.event_name != 'push'
uses: actions/upload-artifact@v4
with:
name: Filesmith-Setup-x64-${{ steps.ver.outputs.version }}-dryrun
path: |
${{ steps.exe.outputs.path }}
dist/resources-manifest.txt
dist/packed-manifest.txt
retention-days: 7
# The installer is already LZMA-compressed.
compression-level: 0

- name: Publish
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false
$v = '${{ steps.ver.outputs.version }}'
$exe = '${{ steps.exe.outputs.path }}'
# A stable-named copy of the same installer, so
# https://github.com/<repo>/releases/latest/download/Filesmith-Setup-x64.exe
# always serves the newest release. The versioned name stays primary.
$stable = 'dist/Filesmith-Setup-x64.exe'
Copy-Item $exe $stable -Force
gh release create "v$v" $exe $stable --title "Filesmith $v" --generate-notes --latest
if ($LASTEXITCODE -ne 0) { throw 'Release publication failed' }
"- published ``v$v``" >> $env:GITHUB_STEP_SUMMARY
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ resources/bin/ bundled CLI binaries (gitignored; fetched by scripts, packed by e
- `npm run package` — electron-vite build + electron-builder NSIS installer to `dist/`.
- `npm run test:e2e` — Playwright end-to-end (launches the built app via `_electron`; run
`npm run build` first). Covers the preload/IPC/engine chain unit tests can't reach.
- Releases: push to main runs `.github/workflows/release.yml` (gates, then requires a NEW
`package.json` version, builds with `fetch-binaries --pinned`, publishes `v<version>`). Bump the
version in the PR. Tool versions + SHA-256 live in `scripts/pinned-tools.mjs`; PRs touching
the release machinery get a dry-run installer artifact.

## Conventions

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "filesmith",
"version": "0.5.0",
"version": "0.5.1",
"description": "A desktop file toolkit: convert, compress, resize, upscale, remove backgrounds, and PDF tools.",
"author": "Max",
"license": "MIT",
Expand Down
74 changes: 55 additions & 19 deletions scripts/fetch-binaries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
* Install the copy-sourced tools first if missing:
* winget install ImageMagick.ImageMagick SaeraSoft.CaesiumCLT ArtifexSoftware.mutool
* (LibreOffice: install from libreoffice.org)
*
* `--pinned` (the CI release build, .github/workflows/release.yml) replaces every
* local source above with a pinned, SHA-256-checked download of the same
* version, staged by scripts/pinned-tools.mjs. Without the flag nothing changes.
*/
import {
existsSync,
Expand All @@ -41,6 +45,7 @@ import { execFileSync } from 'node:child_process'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { tmpdir } from 'node:os'
import { stagePinnedTools } from './pinned-tools.mjs'

const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = join(HERE, '..')
Expand All @@ -51,6 +56,12 @@ mkdirSync(BIN, { recursive: true })
const log = (...a) => console.log(...a)
const mb = (p) => (statSync(p).size / MB).toFixed(1)

/** `--pinned`: the staged tool sources (see pinned-tools.mjs); null = local sources. */
const PINNED_MODE = process.argv.includes('--pinned')
const PINNED_STAGE = join(tmpdir(), 'filesmith-pinned')
/** @type {import('./pinned-tools.mjs').StagedTools | null} */
let PIN = null

/** Locate an executable on PATH via `where`; null if absent. */
function which(name) {
try {
Expand All @@ -66,7 +77,7 @@ function which(name) {
* produces an installer where every image operation fails with "no decode
* delegate" on any machine that has no system ImageMagick to fall back to. */
function bundleImageMagick() {
const magick = which('magick')
const magick = PIN ? join(PIN.magickDir, 'magick.exe') : which('magick')
if (!magick) {
log(' ! ImageMagick not found — skip (winget install ImageMagick.ImageMagick)')
return
Expand Down Expand Up @@ -97,7 +108,7 @@ function bundleImageMagick() {

/** CaesiumCLT: a single self-contained exe. */
function bundleCaesium() {
const c = which('caesiumclt')
const c = PIN ? PIN.caesiumExe : which('caesiumclt')
if (!c) {
log(' ! CaesiumCLT not found — skip (winget install SaeraSoft.CaesiumCLT)')
return
Expand All @@ -117,7 +128,7 @@ function bundleSevenZip() {
join('C:', 'Program Files', '7-Zip'),
join('C:', 'Program Files (x86)', '7-Zip')
].filter(Boolean)
const dir = dirs.find((d) => existsSync(join(d, '7z.exe')))
const dir = PIN ? PIN.sevenZipDir : dirs.find((d) => existsSync(join(d, '7z.exe')))
if (!dir) {
log(' ! 7-Zip not found — skip (winget install 7zip.7zip)')
return
Expand All @@ -133,6 +144,12 @@ function bundleSevenZip() {

/** ffmpeg: download the smaller "essentials" static build and extract ffmpeg.exe. */
async function bundleFfmpeg() {
if (PIN) {
for (const f of ['ffmpeg.exe', 'ffprobe.exe'])
copyFileSync(join(PIN.ffmpegBinDir, f), join(BIN, f))
log(` ✓ ffmpeg: ffmpeg.exe + ffprobe.exe (${mb(join(BIN, 'ffmpeg.exe'))} MB, pinned)`)
return
}
const url = 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip'
const tmp = join(tmpdir(), 'filesmith-ffmpeg')
rmSync(tmp, { recursive: true, force: true })
Expand Down Expand Up @@ -184,7 +201,7 @@ async function bundleFfmpeg() {

/** mutool (MuPDF): a single static exe for the PDF tools. */
function bundleMutool() {
const m = which('mutool')
const m = PIN ? PIN.mutoolExe : which('mutool')
if (!m) {
log(' ! mutool not found — skip (winget install ArtifexSoftware.mutool)')
return
Expand Down Expand Up @@ -213,7 +230,9 @@ function bundleLibreOffice() {
'C:\\Program Files (x86)\\LibreOffice',
process.env.LIBREOFFICE_DIR || ''
].filter(Boolean)
const src = candidates.find((r) => existsSync(join(r, 'program', 'soffice.exe')))
const src = PIN
? PIN.libreOfficeDir
: candidates.find((r) => existsSync(join(r, 'program', 'soffice.exe')))
if (!src) {
log(' ! LibreOffice not found — skip. To bundle it, install LibreOffice')
log(' (winget install TheDocumentFoundation.LibreOffice) or set LIBREOFFICE_DIR')
Expand Down Expand Up @@ -246,6 +265,11 @@ async function bundleGhostscript() {
for (const d of SUBSET)
if (existsSync(join(root, d))) cpSync(join(root, d), join(dest, d), { recursive: true })
}
if (PIN) {
copySubset(PIN.ghostscriptDir)
log(' ✓ Ghostscript: copied from the pinned download')
return
}
// (a) local install: C:\Program Files\gs\gs<ver>\ or $GHOSTSCRIPT_DIR
for (const base of ['C:\\Program Files\\gs', process.env.GHOSTSCRIPT_DIR || ''].filter(Boolean)) {
try {
Expand Down Expand Up @@ -329,12 +353,14 @@ async function bundleRealesrgan() {
cpSync(join(src, m + ext), join(dest, 'models', m + ext))
}
// (a) a local copy (RCMM installs the same binary on demand) or $REALESRGAN_DIR
const local = [
join(process.env.LOCALAPPDATA || '', 'RCMM', 'tools', 'realesrgan'),
process.env.REALESRGAN_DIR || ''
]
.filter(Boolean)
.find((d) => existsSync(join(d, EXE)))
const local = PIN
? PIN.realesrganDir
: [
join(process.env.LOCALAPPDATA || '', 'RCMM', 'tools', 'realesrgan'),
process.env.REALESRGAN_DIR || ''
]
.filter(Boolean)
.find((d) => existsSync(join(d, EXE)))
if (local) {
copySubset(local)
log(` ✓ Real-ESRGAN: copied from ${local}`)
Expand Down Expand Up @@ -506,15 +532,25 @@ if (process.argv.includes('--lo-only')) {
} else if (process.argv.includes('--esrgan-only')) {
await bundleRealesrgan()
} else {
if (PINNED_MODE)
PIN = await stagePinnedTools(PINNED_STAGE, {
allowInstall: process.env.CI === 'true' || process.argv.includes('--pinned-allow-install')
})
log('Populating resources/bin …')
bundleImageMagick()
bundleCaesium()
bundleSevenZip()
await bundleFfmpeg()
bundleMutool()
if (!SKIPPED.has('ghostscript')) await bundleGhostscript()
if (!SKIPPED.has('realesrgan')) await bundleRealesrgan()
if (!SKIPPED.has('libreoffice')) bundleLibreOffice()
try {
bundleImageMagick()
bundleCaesium()
bundleSevenZip()
await bundleFfmpeg()
bundleMutool()
if (!SKIPPED.has('ghostscript')) await bundleGhostscript()
if (!SKIPPED.has('realesrgan')) await bundleRealesrgan()
if (!SKIPPED.has('libreoffice')) bundleLibreOffice()
} finally {
// The staged ImageMagick install leaves registry paths behind; deleting the
// stage makes sure they cannot mask a bundle missing its coder modules.
if (PIN) rmSync(PINNED_STAGE, { recursive: true, force: true })
}

const bundled = readdirSync(BIN).filter((f) => f !== '.gitkeep')
const total = bundled.reduce((s, f) => s + statSync(join(BIN, f)).size, 0)
Expand Down
Loading
Loading