diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a36540c..db71337 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,23 @@ name: CI on: push: pull_request: + # labeled/unlabeled so adding "no release notes" re-runs the check. + types: [opened, synchronize, reopened, labeled, unlabeled] jobs: + release-notes: + name: release notes + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - name: Require CHANGELOG notes for app changes + env: + BASE_REF: origin/${{ github.base_ref }} + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: node scripts/check-release-notes.mjs + - name: One version across core, app and desktop + run: node scripts/version.mjs core: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1585d70..412763c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,33 +1,147 @@ -name: Release desktop +name: Release + +# Builds the desktop installers and the Docker image and publishes them as one +# GitHub release. Same process as Cutawan. +# +# Two ways to cut a release of the version in scripts/version.mjs: +# 1. `node scripts/version.mjs 0.2.0`, commit as "Release v0.2.0" and land it +# on the default branch (handy when the environment can't push tags). +# 2. Push a version tag, e.g. `git tag v0.2.0 && git push --tags`. +# Either way the release tag (vX.Y.Z) comes from the version files, and the +# CHANGELOG.md section for that version becomes the release notes. +# +# Installers are built with --publish never and gathered here; the release +# stays a draft until every platform's installers and update manifests +# (latest*.yml) are uploaded and verified, then is published as Latest. +# The Docker image is pushed only after that, tagged with the version. +# +# Uses the built-in GITHUB_TOKEN, so no extra secrets are required. + on: push: - tags: ["v*"] - workflow_dispatch: + tags: + - 'v*' + branches: + - '**' + workflow_dispatch: {} + +concurrency: + group: metachlorian-release + cancel-in-progress: false + +permissions: + contents: write + jobs: - build: + installers: + # Only build/publish for a tag push, a manual dispatch, or a "Release v…" + # commit to the default branch — every other push skips (a no-op run). + if: >- + github.event_name != 'push' || + startsWith(github.ref, 'refs/tags/v') || + (github.ref_name == github.event.repository.default_branch && + startsWith(github.event.head_commit.message, 'Release v')) strategy: + fail-fast: false matrix: os: [macos-latest, windows-latest, ubuntu-latest] runs-on: ${{ matrix.os }} - permissions: { contents: write } # electron-builder uploads installers to a draft release steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - with: { node-version: 22 } + with: + node-version: 22 + cache: npm + cache-dependency-path: | + app/package-lock.json + desktop/package-lock.json + # Every release says what changed and ships one version; fail before spending build time. + - name: Check version and release notes + shell: bash + run: | + version=$(node scripts/version.mjs) + if [[ "$GITHUB_REF" == refs/tags/v* && "$GITHUB_REF_NAME" != "v$version" ]]; then + echo "Tag $GITHUB_REF_NAME does not match version $version"; exit 1 + fi + node scripts/changelog-section.mjs "$version" --require > /dev/null - name: Build web app working-directory: app run: npm ci && npm run build - - name: Build installer (unsigned until certificates are configured, see OPEN_QUESTIONS Q3) + - name: Build installers (unsigned until certificates are configured, see OPEN_QUESTIONS Q3) working-directory: desktop + shell: bash env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Signing: set CSC_LINK / CSC_KEY_PASSWORD (Windows, macOS) and APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / # APPLE_TEAM_ID (notarisation) as repository secrets, then remove "identity": null from package.json. - CSC_IDENTITY_AUTO_DISCOVERY: "false" - run: npm ci && npx electron-builder --publish always + # Until then macOS builds are ad-hoc signed by desktop/scripts/adhoc-sign-mac.mjs. + CSC_IDENTITY_AUTO_DISCOVERY: false + run: | + npm ci + npm test + npx electron-builder --publish never ${{ runner.os == 'macOS' && '--arm64' || '' }} + - uses: actions/upload-artifact@v4 + with: + name: installers-${{ runner.os }} + if-no-files-found: error + path: | + desktop/release/*.dmg + desktop/release/*.zip + desktop/release/*.exe + desktop/release/*.AppImage + desktop/release/*.blockmap + desktop/release/latest*.yml + + publish: + name: publish complete release + needs: installers + runs-on: ubuntu-latest + outputs: + version: ${{ steps.publish.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: desktop/package-lock.json + - name: Install verifier dependencies + working-directory: desktop + run: npm ci --ignore-scripts + - uses: actions/download-artifact@v4 + with: + pattern: installers-* + path: release + merge-multiple: true + - name: Verify all platforms and publish together + id: publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + node scripts/verify-release.mjs release + version=$(node scripts/version.mjs) + echo "version=$version" >> "$GITHUB_OUTPUT" + node scripts/changelog-section.mjs "$version" --require > notes.md + if gh release view "v$version" --json isDraft > release-state.json; then + node -e 'if (!require("./release-state.json").isDraft) throw new Error("This version is already published; bump the version before releasing.")' + else + gh release create "v$version" --draft --target "$GITHUB_SHA" --title "v$version" --notes-file notes.md + fi + gh release upload "v$version" release/* --clobber + # A fresh draft has no public tag yet; resolve it through gh's draft-aware lookup. + release_id=$(gh release view "v$version" --json databaseId --jq '.databaseId') + gh api "repos/$GITHUB_REPOSITORY/releases/$release_id" > uploaded-release.json + node scripts/verify-release.mjs release uploaded-release.json + gh release edit "v$version" --target "$GITHUB_SHA" --notes-file notes.md --draft=false --latest + image: + name: publish Docker image + needs: publish runs-on: ubuntu-latest - permissions: { packages: write, contents: read } + permissions: + packages: write + contents: read steps: - uses: actions/checkout@v4 # Block mappings: a ${{ }} expression inside a { } flow mapping is invalid YAML and stopped every run. @@ -39,4 +153,4 @@ jobs: - uses: docker/build-push-action@v6 with: push: true - tags: ghcr.io/jeremysnr/metachlorian:latest,ghcr.io/jeremysnr/metachlorian:${{ github.ref_name }} + tags: ghcr.io/jeremysnr/metachlorian:latest,ghcr.io/jeremysnr/metachlorian:v${{ needs.publish.outputs.version }},ghcr.io/jeremysnr/metachlorian:${{ needs.publish.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2a3fd6e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +Notable changes per release. Full commit history, desktop installers and Docker +image tags are on the [releases page](https://github.com/JeremySNR/Metachlorian/releases). + +This project uses [semantic versioning](https://semver.org/), loosely: while +still pre-1.0, minor bumps carry new features and patch bumps carry fixes. + +## [Unreleased] + +### Fixed + +- The macOS desktop download no longer opens with "Metachlorian is damaged and can't be opened". The app is now ad-hoc signed, so macOS shows its usual unidentified-developer prompt and **System Settings → Privacy & Security → Open Anyway** works. It is still not signed with an Apple Developer ID or notarised. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9ba8aac --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing + +## Release notes + +Every change people will notice needs a line in `CHANGELOG.md` under +`## [Unreleased]`, grouped as `### Added`, `### Improved` or `### Fixed`. CI +fails a pull request that changes the core (`core/metachlorian/`), the web app +(`app/src/`), the desktop shell (`desktop/src/`), the Docker image or their +dependencies without one. For internal-only changes (tests, CI, docs, +refactors with no visible effect), add the `no release notes` label instead. + +These lines are what people read: the release workflow publishes a version's +section as its GitHub release notes. So write them for someone using +Metachlorian, not for a code reviewer: + +- Say what changed for them: "Search finds shots by what people say in them" + rather than "Add transcript FTS to the hybrid ranker". +- One change per bullet, plain words, no file or function names. +- Measurements, failed trials and limits go in a `### Validation` section (or + a doc linked from it). + +## Releasing + +The core, web app, desktop app and Docker image share one version, kept in +step by `scripts/version.mjs` (CI fails if they drift). + +1. Run `node scripts/version.mjs 0.2.0`. It sets the version in + `core/pyproject.toml`, `core/metachlorian/__init__.py`, `app/` and + `desktop/`, and renames `## [Unreleased]` to `## [0.2.0] - `. +2. Commit those changes with a message starting `Release v0.2.0` and land it on + the default branch (or push a `v0.2.0` tag instead). + +The [release workflow](.github/workflows/release.yml) then: + +- refuses to build a version with no notes, or a tag that doesn't match the + version; +- builds the macOS (Apple Silicon), Windows and Linux installers; +- uploads them to a draft release, checks every installer against its update + manifest (`latest*.yml`) by size and checksum, locally and on GitHub, and + only then publishes the release as Latest with the changelog section as its + notes; +- pushes the Docker image as `ghcr.io/jeremysnr/metachlorian:`, + `:v` and `:latest`. + +A version that is already published can't be released again; bump it. If a +run fails partway, re-run it: the draft is reused and assets are replaced. + +Model weights are published separately, by hand, with the **Publish models** +workflow (`.github/workflows/models.yml`). diff --git a/desktop/package.json b/desktop/package.json index 081eaee..1112a73 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -18,6 +18,7 @@ "build": { "appId": "org.metachlorian.app", "productName": "Metachlorian", + "afterPack": "./scripts/adhoc-sign-mac.mjs", "directories": { "output": "release" }, @@ -47,6 +48,9 @@ "win": { "target": "nsis" }, + "nsis": { + "artifactName": "${productName}-Setup-${version}.${ext}" + }, "linux": { "target": "AppImage", "category": "AudioVideo", @@ -66,4 +70,4 @@ "icon": "assets/icon.png" }, "desktopName": "metachlorian.desktop" -} \ No newline at end of file +} diff --git a/desktop/scripts/adhoc-sign-mac.mjs b/desktop/scripts/adhoc-sign-mac.mjs new file mode 100644 index 0000000..fb8365c --- /dev/null +++ b/desktop/scripts/adhoc-sign-mac.mjs @@ -0,0 +1,23 @@ +/** + * electron-builder afterPack hook: ad-hoc signs the packaged macOS app. + * + * There is no Developer ID yet, so electron-builder's own signing is off + * (`mac.identity: null`). Packaging edits the bundle after Electron's original + * signature was made, which leaves it invalid, and macOS on Apple Silicon then + * reports a downloaded copy as "damaged" with no way to open it. An ad-hoc + * signature keeps the bundle intact, so Gatekeeper shows its usual + * unidentified-developer prompt and Privacy & Security → Open Anyway works. + * + * It runs before the dmg/zip are built, so both carry the signed app. + * Same approach as Cutawan (scripts/adhoc-sign-mac.mjs there). + */ +import { execFileSync } from 'node:child_process' +import path from 'node:path' + +export default async function adhocSignMac(context) { + if (context.electronPlatformName !== 'darwin') return + const app = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`) + execFileSync('codesign', ['--force', '--deep', '--sign', '-', app], { stdio: 'inherit' }) + execFileSync('codesign', ['--verify', '--deep', '--strict', app], { stdio: 'inherit' }) + console.log(` • ad-hoc signed ${app}`) +} diff --git a/scripts/changelog-section.mjs b/scripts/changelog-section.mjs new file mode 100644 index 0000000..ec7493b --- /dev/null +++ b/scripts/changelog-section.mjs @@ -0,0 +1,38 @@ +/** + * Prints the CHANGELOG.md section for a given version, for use as GitHub + * release notes. + * + * Plain Node ESM with no dependencies, so the release workflow can run it + * before any `npm ci`. + * + * Usage: node scripts/changelog-section.mjs 0.2.0 [--require] + * With --require (the release workflow), a missing or empty section fails: + * every release must say what changed. Without it, prints nothing. + */ +import { readFile } from 'node:fs/promises' + +const version = process.argv[2] +const required = process.argv.includes('--require') +if (!version || version.startsWith('--')) { + console.error('usage: node scripts/changelog-section.mjs [--require]') + process.exit(2) +} + +const text = await readFile(new URL('../CHANGELOG.md', import.meta.url), 'utf8') +const lines = text.split(/\r?\n/) + +// Section headings look like `## [0.2.0] - 2026-10-13`. Match the version +// inside the brackets so the date is free to change format. +const isHeading = (line) => /^##\s+/.test(line) +const headingVersion = (line) => line.match(/^##\s+\[?([0-9]+\.[0-9]+\.[0-9]+)\]?/)?.[1] + +const start = lines.findIndex((l) => headingVersion(l) === version) +const rest = start === -1 ? [] : lines.slice(start + 1) +const end = rest.findIndex(isHeading) +const body = (end === -1 ? rest : rest.slice(0, end)).join('\n').trim() + +if (!body && required) { + console.error(`CHANGELOG.md has no notes for ${version}. Run "node scripts/version.mjs ${version}" (or rename "## [Unreleased]" to "## [${version}] - ") before releasing.`) + process.exit(1) +} +process.stdout.write(body) diff --git a/scripts/check-release-notes.mjs b/scripts/check-release-notes.mjs new file mode 100644 index 0000000..4c32978 --- /dev/null +++ b/scripts/check-release-notes.mjs @@ -0,0 +1,77 @@ +/** + * Pull-request gate: a change people will notice needs release notes. + * + * If the diff against the base branch touches something people run (the core, + * the web app, the desktop shell, the Docker image or their dependencies) it + * must also add notes under `## [Unreleased]` in CHANGELOG.md; the release + * workflow later publishes that section as the GitHub release notes. + * Internal-only changes (tests, CI, docs, refactors with no visible effect) + * can carry the `no release notes` label. + * + * Usage: BASE_REF=origin/main PR_LABELS='["..."]' node scripts/check-release-notes.mjs + */ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' + +const baseRef = process.env.BASE_REF || 'origin/main' +// Accept a JSON list, a single JSON string or a bare name, whatever the +// workflow expression renders to. +const parseLabels = (raw) => { + if (!raw) return [] + try { + const value = JSON.parse(raw) + return (Array.isArray(value) ? value : [value]).map((l) => String(l).toLowerCase()) + } catch { + return [raw.toLowerCase()] + } +} +const labels = parseLabels(process.env.PR_LABELS) +if (labels.includes('no release notes')) { + console.log('Skipped: labelled "no release notes".') + process.exit(0) +} + +const git = (...args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +const changed = git('diff', '--name-only', `${baseRef}...HEAD`).split('\n').filter(Boolean) +const userFacing = changed.filter((f) => + /^(core\/metachlorian|app\/src|app\/public|desktop\/src|desktop\/assets|deploy)\//.test(f) || + ['core/pyproject.toml', 'app/package.json', 'desktop/package.json', 'Dockerfile', 'scripts/install.sh'].includes(f)) +if (!userFacing.length) { + console.log('No app changes; release notes not required.') + process.exit(0) +} + +// Bullets under [Unreleased] and under every version section. A release PR +// renames [Unreleased] to the new version, so a new or changed section of +// either kind counts as release notes. +const sections = (text) => { + const out = new Map() + let name = null + for (const line of text.split(/\r?\n/)) { + const heading = line.match(/^##\s+\[?([^\]\s]+)\]?/) + if (heading) { name = heading[1].toLowerCase(); out.set(name, []); continue } + if (name && /^\s*[-*]\s+\S/.test(line)) out.get(name).push(line.trim()) + } + return out +} +let baseSections = new Map() +try { baseSections = sections(git('show', `${baseRef}:CHANGELOG.md`)) } catch { /* new file */ } +let head = new Map() +try { head = sections(readFileSync('CHANGELOG.md', 'utf8')) } catch { /* deleted: no notes */ } +const added = [...head].filter(([name, bullets]) => + (name === 'unreleased' || /^[0-9]+\.[0-9]+\.[0-9]+$/.test(name)) && bullets.length && + bullets.join('\n') !== (baseSections.get(name) ?? []).join('\n')) + +if (!added.length) { + console.error([ + 'This pull request changes the app but adds no release notes.', + '', + 'Add a line under "## [Unreleased]" in CHANGELOG.md (### Added, ### Improved or', + '### Fixed) saying what changed for people using Metachlorian. If nothing visible', + 'changed, add the "no release notes" label.', + '', + `App files changed: ${userFacing.slice(0, 8).join(', ')}${userFacing.length > 8 ? ', …' : ''}` + ].join('\n')) + process.exit(1) +} +console.log(`Release notes present: ${added.map(([name]) => `[${name}]`).join(', ')}.`) diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..4d8c8bc --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,50 @@ +// Refuse to publish an incomplete release or a manifest pointing at bad bytes. +// +// node scripts/verify-release.mjs local files match the latest*.yml manifests +// node scripts/verify-release.mjs uploaded-release.json ...and so do the assets GitHub holds +// +// Runs after `npm ci` in desktop/, whose electron-builder brings js-yaml. +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { createRequire } from 'node:module' +import { execFileSync } from 'node:child_process' + +const require = createRequire(new URL('../desktop/package.json', import.meta.url)) +const yaml = require('js-yaml') +const version = execFileSync(process.execPath, [new URL('version.mjs', import.meta.url).pathname], { encoding: 'utf8' }) +const directory = process.argv[2] ?? 'release' +const uploaded = process.argv[3] ? JSON.parse(await readFile(process.argv[3], 'utf8')).assets : null +if (process.argv[3] && !Array.isArray(uploaded)) throw new Error('Missing uploaded asset metadata') + +// Each platform's update manifest and the installer types it must list. +const expected = { + 'latest-mac.yml': ['.dmg', '.zip'], + 'latest.yml': ['.exe'], + 'latest-linux.yml': ['.AppImage'] +} +let count = 0 +for (const [manifest, kinds] of Object.entries(expected)) { + const data = yaml.load(await readFile(join(directory, manifest), 'utf8')) + if (data.version !== version || !Array.isArray(data.files)) throw new Error(`Invalid ${manifest} (expected version ${version})`) + for (const kind of kinds) { + if (!data.files.some((file) => file.url.endsWith(kind) && file.url.includes(version))) throw new Error(`${manifest} lists no ${kind} for ${version}`) + } + for (const file of data.files) { + if (typeof file.url !== 'string' || basename(file.url) !== file.url) throw new Error(`Invalid asset in ${manifest}`) + const path = join(directory, file.url) + if ((await stat(path)).size !== file.size) throw new Error(`Incorrect size: ${file.url}`) + const hash = createHash('sha512') + const sha256 = createHash('sha256') + for await (const chunk of createReadStream(path)) { hash.update(chunk); sha256.update(chunk) } + if (hash.digest('base64') !== file.sha512) throw new Error(`Incorrect checksum: ${file.url}`) + if (uploaded) { + const remote = uploaded.find((asset) => asset.name === file.url) + if (!remote || remote.state !== 'uploaded' || remote.size !== file.size || + remote.digest !== `sha256:${sha256.digest('hex')}`) throw new Error(`Upload not verified: ${file.url}`) + } + count++ + } +} +console.log(`All v${version} installers (${count}) and update manifests verified${uploaded ? ' on GitHub' : ''}.`) diff --git a/scripts/version.mjs b/scripts/version.mjs new file mode 100644 index 0000000..b577497 --- /dev/null +++ b/scripts/version.mjs @@ -0,0 +1,67 @@ +/** + * One version for everything Metachlorian ships: the Python core, the web app, + * the desktop app and the Docker image all carry the same number. + * + * node scripts/version.mjs print it; fail if the files disagree + * node scripts/version.mjs 0.2.0 set it everywhere and turn + * "## [Unreleased]" into "## [0.2.0] - " + * + * Commit the result as "Release v0.2.0" to publish (see CONTRIBUTING.md). + */ +import { readFileSync, writeFileSync } from 'node:fs' + +const root = new URL('../', import.meta.url) +const read = (p) => readFileSync(new URL(p, root), 'utf8') +const write = (p, text) => writeFileSync(new URL(p, root), text) + +// Each entry reads the version from a file and can rewrite it. +const regexFile = (path, re) => ({ + path, + get: () => read(path).match(re)?.[2], + set: (v) => write(path, read(path).replace(re, `$1${v}$3`)) +}) +const jsonFile = (path, lock) => ({ + path, + get: () => JSON.parse(read(path)).version, + set: (v) => { + const data = JSON.parse(read(path)) + data.version = v + if (lock && data.packages?.['']) data.packages[''].version = v + write(path, JSON.stringify(data, null, 2) + '\n') + } +}) +const files = [ + regexFile('core/pyproject.toml', /^(version = ")([^"]+)(")/m), + regexFile('core/metachlorian/__init__.py', /^(__version__ = ")([^"]+)(")/m), + jsonFile('app/package.json'), + jsonFile('app/package-lock.json', true), + jsonFile('desktop/package.json'), + jsonFile('desktop/package-lock.json', true) +] + +const next = process.argv[2] +if (!next) { + const found = files.map((f) => [f.path, f.get()]) + const versions = new Set(found.map(([, v]) => v)) + if (versions.size !== 1) { + console.error('Version mismatch:\n' + found.map(([p, v]) => ` ${p}: ${v}`).join('\n') + '\nRun: node scripts/version.mjs ') + process.exit(1) + } + process.stdout.write([...versions][0]) + process.exit(0) +} + +if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(next)) { + console.error(`Not a version: ${next} (expected x.y.z)`) + process.exit(2) +} +for (const f of files) f.set(next) + +const changelog = read('CHANGELOG.md') +const today = new Date().toISOString().slice(0, 10) +if (/^## \[Unreleased\]/m.test(changelog)) { + write('CHANGELOG.md', changelog.replace(/^## \[Unreleased\].*$/m, `## [${next}] - ${today}`)) + console.log(`Set ${next} in ${files.length} files and dated the [Unreleased] notes.`) +} else { + console.log(`Set ${next} in ${files.length} files. CHANGELOG.md has no [Unreleased] section; add "## [${next}] - ${today}" notes before releasing.`) +}