Skip to content
Closed
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
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
138 changes: 126 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 }}
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 49 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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] - <today>`.
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:<version>`,
`:v<version>` 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`).
6 changes: 5 additions & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"build": {
"appId": "org.metachlorian.app",
"productName": "Metachlorian",
"afterPack": "./scripts/adhoc-sign-mac.mjs",
"directories": {
"output": "release"
},
Expand Down Expand Up @@ -47,6 +48,9 @@
"win": {
"target": "nsis"
},
"nsis": {
"artifactName": "${productName}-Setup-${version}.${ext}"
},
"linux": {
"target": "AppImage",
"category": "AudioVideo",
Expand All @@ -66,4 +70,4 @@
"icon": "assets/icon.png"
},
"desktopName": "metachlorian.desktop"
}
}
23 changes: 23 additions & 0 deletions desktop/scripts/adhoc-sign-mac.mjs
Original file line number Diff line number Diff line change
@@ -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}`)
}
38 changes: 38 additions & 0 deletions scripts/changelog-section.mjs
Original file line number Diff line number Diff line change
@@ -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 <version> [--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}] - <date>") before releasing.`)
process.exit(1)
}
process.stdout.write(body)
Loading
Loading