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
87 changes: 71 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
name: Release

# Pushing a tag vX.Y.Z (matching the workspace version) builds the signed
# installer and the setup window around it, and opens a draft release;
# publishing it makes the in-app updater offer it (spec 15.5, 15.7).
# Pushing a tag vX.Y.Z (matching the workspace version) builds Botloft for
# Windows, Linux and macOS, each on its own runner, and opens one draft
# release with all of them; publishing it makes the in-app updater offer it
# (spec 15.5, 15.7). Run by hand on a branch, it builds the same files and
# keeps them as workflow artifacts, without attesting or releasing anything.
on:
push:
tags: ["v*"]
workflow_dispatch:

permissions:
contents: write
# A build provenance attestation for each installer (spec 15.5): anyone can
# A build provenance attestation for each download (spec 15.5): anyone can
# check with `gh attestation verify` that it came from this workflow.
id-token: write
attestations: write
Expand All @@ -19,19 +22,44 @@ env:
CARGO_TERM_COLOR: always
# Code signing (spec 15.5). With the certificate's thumbprint in the
# repository variable BOTLOFT_SIGN_THUMBPRINT, scripts/sign.mjs signs every
# binary; without it the release goes out unsigned, as today. The step that
# puts the certificate in the runner's certificate store (signing in to the
# cloud key's client) goes before "Build the signed installer".
# Windows binary; without it the release goes out unsigned, as today. The
# step that puts the certificate in the runner's certificate store
# (signing in to the cloud key's client) goes before "Build the signed
# installer".
BOTLOFT_SIGN_THUMBPRINT: ${{ vars.BOTLOFT_SIGN_THUMBPRINT }}
BOTLOFT_SIGN_TIMESTAMP: ${{ vars.BOTLOFT_SIGN_TIMESTAMP }}
# The updater's key signs every file the updater installs.
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}

jobs:
installer:
name: Installer (Windows)
runs-on: windows-latest
build:
name: Build (${{ matrix.system }})
strategy:
fail-fast: false
matrix:
include:
- system: windows
os: windows-latest
files: target/release/bundle/release/*.exe
# The oldest supported Ubuntu, so the AppImage runs on older
# distributions too (it uses the glibc it was built with).
- system: linux
os: ubuntu-22.04
files: |
target/release/bundle/release/*.AppImage
target/release/bundle/release/*.deb
# Apple Silicon.
- system: macos
os: macos-latest
files: |
target/release/bundle/release/*.dmg
target/release/bundle/release/*.app.tar.gz
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
- run: node app/scripts/release.mjs check
- if: github.ref_type == 'tag'
run: node app/scripts/release.mjs check
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: pnpm/action-setup@v4
Expand All @@ -42,23 +70,50 @@ jobs:
node-version: 24
cache: pnpm
cache-dependency-path: app/pnpm-lock.yaml
- name: Desktop libraries (Linux)
if: matrix.system == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev file
- run: pnpm install --frozen-lockfile
working-directory: app
- name: Build the signed installer
run: pnpm bundle
working-directory: app
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# linuxdeploy, which makes the AppImage, is an AppImage too; the
# runner has no FUSE to mount it.
APPIMAGE_EXTRACT_AND_RUN: "1"
- name: Build the setup window around it
if: matrix.system == 'windows'
run: pnpm bundle:setup
working-directory: app
- name: Stage the release files
run: node app/scripts/release.mjs stage
- name: Attest where the installers come from
run: node app/scripts/release.mjs stage ${{ matrix.system }}
- name: Attest where the downloads come from
if: github.ref_type == 'tag'
uses: actions/attest@v4
with:
subject-path: target/release/bundle/release/*.exe
subject-path: ${{ matrix.files }}
- uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.system }}
path: target/release/bundle/release
if-no-files-found: error

publish:
name: Draft release
if: github.ref_type == 'tag'
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v4
with:
pattern: release-*
path: target/release/bundle/release
merge-multiple: true
- name: Open a draft release
run: node app/scripts/release.mjs publish
env:
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pnpm local # compila em release e troca o app e o botloftd do Botloft ins

O `pnpm local` usa os dados reais (`%LOCALAPPDATA%\Botloft`), não os de dev. As migrations novas rodam no banco real, e um daemon mais antigo deixa de abrir esse banco. O atualizador continua oferecendo releases de versão maior.

Release (spec 15.5): suba a versão em `[workspace.package]` do `Cargo.toml` (e no `app/package.json`), faça merge, crie a tag `vX.Y.Z` e dê push. O workflow `release.yml` abre um release rascunho com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) com o `.sig` e o `latest.json`; o updater só o vê depois de publicado. Os dois `.exe` ganham um atestado de origem (`gh attestation verify`), e as notas do rascunho começam com os SHA-256. `node app/scripts/release.mjs stage` monta esses arquivos localmente para conferir. A assinatura de código passa por `app/scripts/sign.mjs` e só liga com a variável `BOTLOFT_SIGN_THUMBPRINT` (spec 15.5); a política pública fica em `docs/code-signing-policy.md`. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou.
Release (spec 15.5): suba a versão em `[workspace.package]` do `Cargo.toml` (e no `app/package.json`), faça merge, crie a tag `vX.Y.Z` e dê push. O workflow `release.yml` abre um release rascunho com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) com o `.sig` e o `latest.json`; o updater só o vê depois de publicado. Os dois `.exe` ganham um atestado de origem (`gh attestation verify`), e as notas do rascunho começam com os SHA-256. `node app/scripts/release.mjs stage windows` monta esses arquivos localmente para conferir. A mesma tag gera o AppImage e o `.deb` do Linux e o `.dmg` do macOS (sem notarização), em jobs próprios; rodar o workflow à mão faz esses arquivos sem release. A assinatura de código passa por `app/scripts/sign.mjs` e só liga com a variável `BOTLOFT_SIGN_THUMBPRINT` (spec 15.5); a política pública fica em `docs/code-signing-policy.md`. A chave privada do updater fica no segredo `TAURI_SIGNING_PRIVATE_KEY`; perdê-la impede atualizar quem já instalou.

Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon e no app, para não tocar na instalação real em `%LOCALAPPDATA%\Botloft`. O `config.toml` de dev fica dentro dessa pasta: o app lê a porta de lá.

Expand Down
181 changes: 125 additions & 56 deletions app/scripts/release.mjs
Original file line number Diff line number Diff line change
@@ -1,36 +1,41 @@
// Release steps for .github/workflows/release.yml (spec 15.5, 15.7).
//
// node app/scripts/release.mjs check the tag matches the workspace version
// node app/scripts/release.mjs stage the release files, with their final names,
// in target/release/bundle/release
// node app/scripts/release.mjs publish a draft GitHub release with what `stage` left
// node app/scripts/release.mjs check the tag matches the workspace version
// node app/scripts/release.mjs stage <system> this system's release files, with their
// final names, in target/release/bundle/release,
// and <system>.json saying what they are
// node app/scripts/release.mjs publish a draft GitHub release with what every
// system staged, and latest.json for all
//
// A release carries two installers: the setup window people download
// (`-setup.exe`, from `pnpm bundle:setup`) and the NSIS installer the in-app
// updater runs (`-update.exe`, from `pnpm bundle`, with its signature).
// latest.json, the feed the updater reads from the latest published release,
// points to the second. A draft is invisible to it until the owner publishes
// the release. Between `stage` and `publish`, the workflow attests where the
// installers come from; the draft's notes open with their SHA-256 and how to
// check that attestation.
// Each system builds on its own runner: windows, linux and macos. Windows
// carries the setup window people download (`-setup.exe`, from `pnpm
// bundle:setup`) and the NSIS installer the in-app updater runs
// (`-update.exe`). Linux carries an AppImage and a .deb; macOS a .dmg and
// the .app.tar.gz the updater installs. Each updater file comes with its
// signature. latest.json, the feed the updater reads from the latest
// published release, names one file per system and installer; a draft is
// invisible to it until the owner publishes the release. Between `stage`
// and `publish`, each runner attests where its files come from; the
// draft's notes open with their SHA-256 and how to check that attestation.

import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { root, workspaceVersion } from "./workspace.mjs";

const tag = process.env.GITHUB_REF_NAME ?? "";
const repo = process.env.GITHUB_REPOSITORY ?? "";
const bundle = join(process.env.CARGO_TARGET_DIR ?? join(root, "target"), "release", "bundle");
const staged = join(bundle, "release");

function names(version) {
return {
setup: `Botloft_${version}_x64-setup.exe`,
update: `Botloft_${version}_x64-update.exe`,
};
}
const SYSTEMS = ["windows", "linux", "macos"];

function check() {
const version = workspaceVersion();
Expand All @@ -40,32 +45,65 @@ function check() {
console.log(`releasing ${version}`);
}

/** Copies the release files under their final names and writes latest.json. */
function stage() {
/** The one file in `bundle/<dir>` whose name ends with `suffix`. */
function built(dir, suffix) {
const found = readdirSync(join(bundle, dir)).filter((name) => name.endsWith(suffix));
if (found.length !== 1) {
throw new Error(`expected one *${suffix} in bundle/${dir}, found ${found.length}`);
}
return join(bundle, dir, found[0]);
}

/** What each system ships: [final name, built file, updater key or null]. */
function outputs(system, version) {
if (system === "windows") {
const exe = `Botloft_${version}_x64-setup.exe`;
return [
[`Botloft_${version}_x64-setup.exe`, join(bundle, "setup", exe), null],
[`Botloft_${version}_x64-update.exe`, join(bundle, "nsis", exe), "windows-x86_64"],
];
}
if (system === "linux") {
return [
[
`Botloft_${version}_amd64.AppImage`,
built("appimage", ".AppImage"),
"linux-x86_64-appimage",
],
[`Botloft_${version}_amd64.deb`, built("deb", ".deb"), "linux-x86_64-deb"],
];
}
if (system === "macos") {
const arch = process.arch === "arm64" ? "aarch64" : "x86_64";
return [
[`Botloft_${version}_${arch}.dmg`, built("dmg", ".dmg"), null],
[`Botloft_${version}_${arch}.app.tar.gz`, built("macos", ".app.tar.gz"), `darwin-${arch}`],
];
}
throw new Error(`usage: release.mjs stage ${SYSTEMS.join("|")}`);
}

/**
* Copies this system's files under their final names, with the updater's
* signatures, and writes <system>.json for `publish`.
*/
function stage(system) {
const version = workspaceVersion();
const built = `Botloft_${version}_x64-setup.exe`;
const { setup, update } = names(version);
rmSync(staged, { recursive: true, force: true });
mkdirSync(staged, { recursive: true });

copyFileSync(join(bundle, "setup", built), join(staged, setup));
copyFileSync(join(bundle, "nsis", built), join(staged, update));
copyFileSync(join(bundle, "nsis", `${built}.sig`), join(staged, `${update}.sig`));
const signature = readFileSync(join(staged, `${update}.sig`), "utf8").trim();
const feed = {
version,
notes: "",
pub_date: new Date().toISOString(),
platforms: {
"windows-x86_64": {
signature,
url: `https://github.com/${repo}/releases/download/${tag}/${update}`,
},
},
};
writeFileSync(join(staged, "latest.json"), `${JSON.stringify(feed, null, 2)}\n`);
console.log(`staged in ${staged}`);
for (const name of [setup, update]) {
const manifest = { files: [], updater: {} };
for (const [name, from, key] of outputs(system, version)) {
copyFileSync(from, join(staged, name));
manifest.files.push(name);
if (key) {
copyFileSync(`${from}.sig`, join(staged, `${name}.sig`));
manifest.files.push(`${name}.sig`);
const signature = readFileSync(`${from}.sig`, "utf8").trim();
manifest.updater[key] = { file: name, signature };
}
}
writeFileSync(join(staged, `${system}.json`), `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`staged ${system} in ${staged}`);
for (const name of manifest.files.filter((name) => !name.endsWith(".sig"))) {
console.log(` ${sha256(join(staged, name))} ${name}`);
}
}
Expand All @@ -74,36 +112,67 @@ function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}

/** The start of the release notes: how to check a download. */
function verifyNotes(version) {
const { setup, update } = names(version);
const rows = [setup, update]
/** The manifests every system staged, in a fixed order. */
function manifests() {
return SYSTEMS.filter((system) => existsSync(join(staged, `${system}.json`))).map((system) => ({
system,
...JSON.parse(readFileSync(join(staged, `${system}.json`), "utf8")),
}));
}

/** The start of the release notes: how to check a download, and how to install off Windows. */
function notes(all) {
const downloads = all.flatMap(({ files }) => files.filter((name) => !name.endsWith(".sig")));
const rows = downloads
.map((name) => `| \`${name}\` | \`${sha256(join(staged, name))}\` |`)
.join("\n");
const setup = downloads.find((name) => name.endsWith("-setup.exe")) ?? downloads[0];
return `### Verify your download

| File | SHA-256 |
|---|---|
${rows}

Both installers were built by GitHub Actions from this repository, which attested where they come from. To check a download with the [GitHub CLI](https://cli.github.com/):
Every file was built by GitHub Actions from this repository, which attested where it comes from. To check a download with the [GitHub CLI](https://cli.github.com/):

\`\`\`
gh attestation verify ${setup} --repo ${repo}
\`\`\`

How releases are made, and what Botloft changes on your computer: [Code signing policy](https://github.com/${repo}/blob/main/docs/code-signing-policy.md).

### Linux and macOS

- **Linux:** the \`.deb\` for Debian and Ubuntu, or the \`.AppImage\` for any other distribution (make it executable first).
- **macOS (Apple Silicon):** Apple has not notarized the app yet. Open the \`.dmg\`, drag Botloft to Applications and open it once; then allow it in System Settings → Privacy & Security → Open Anyway.
`;
}

function publish() {
const version = workspaceVersion();
const { setup, update } = names(version);
const files = [setup, update, `${update}.sig`, "latest.json"].map((name) => join(staged, name));
const all = manifests();
if (all.length === 0) {
throw new Error("run `release.mjs stage <system>` first: nothing is staged");
}
const platforms = {};
for (const { updater } of all) {
for (const [key, { file, signature }] of Object.entries(updater)) {
platforms[key] = {
signature,
url: `https://github.com/${repo}/releases/download/${tag}/${file}`,
};
}
}
const feed = { version, notes: "", pub_date: new Date().toISOString(), platforms };
writeFileSync(join(staged, "latest.json"), `${JSON.stringify(feed, null, 2)}\n`);
const files = [...all.flatMap(({ files }) => files), "latest.json"].map((name) =>
join(staged, name),
);
const missing = files.filter((file) => !existsSync(file));
if (missing.length > 0) {
throw new Error(`run \`release.mjs stage\` first; missing:\n ${missing.join("\n ")}`);
throw new Error(`staged files are missing:\n ${missing.join("\n ")}`);
}
console.log(`publishing ${all.map(({ system }) => system).join(", ")}`);
execFileSync(
"gh",
[
Expand All @@ -116,20 +185,20 @@ function publish() {
`Botloft ${version}`,
// GitHub puts these notes before the ones it generates.
"--notes",
verifyNotes(version),
notes(all),
"--generate-notes",
],
{ stdio: "inherit" },
);
}

const step = process.argv[2];
const [step, system] = process.argv.slice(2);
if (step === "check") {
check();
} else if (step === "stage") {
stage();
stage(system);
} else if (step === "publish") {
publish();
} else {
throw new Error("usage: release.mjs check|stage|publish");
throw new Error("usage: release.mjs check|stage <system>|publish");
}
6 changes: 6 additions & 0 deletions app/src-tauri/tauri.linux.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"targets": ["deb", "appimage"]
}
}
Loading
Loading