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
11 changes: 11 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ on:

permissions:
contents: write
# A build provenance attestation for each installer (spec 15.5): anyone can
# check with `gh attestation verify` that it came from this workflow.
id-token: write
attestations: write
artifact-metadata: write

env:
CARGO_TERM_COLOR: always
Expand Down Expand Up @@ -41,6 +46,12 @@ jobs:
- name: Build the setup window around it
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
uses: actions/attest@v4
with:
subject-path: target/release/bundle/release/*.exe
- 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 @@ -46,7 +46,7 @@ pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release
pnpm bundle:setup # tela de instalação com esse NSIS dentro (spec 15.7), em target\release\bundle\setup
```

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. `node app/scripts/release.mjs stage` monta esses arquivos localmente para conferir. 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` monta esses arquivos localmente para conferir. 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
79 changes: 61 additions & 18 deletions app/scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,34 @@
// 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 stage, then a draft GitHub release with them
// node app/scripts/release.mjs publish a draft GitHub release with what `stage` left
//
// 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.
// 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.

import { execFileSync } from "node:child_process";
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, 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`,
};
}

function check() {
const version = workspaceVersion();
Expand All @@ -31,18 +43,15 @@ function check() {
/** Copies the release files under their final names and writes latest.json. */
function stage() {
const version = workspaceVersion();
const bundle = join(process.env.CARGO_TARGET_DIR ?? join(root, "target"), "release", "bundle");
const built = `Botloft_${version}_x64-setup.exe`;
const setup = `Botloft_${version}_x64-setup.exe`;
const update = `Botloft_${version}_x64-update.exe`;
const out = join(bundle, "release");
rmSync(out, { recursive: true, force: true });
mkdirSync(out, { recursive: true });
const { setup, update } = names(version);
rmSync(staged, { recursive: true, force: true });
mkdirSync(staged, { recursive: true });

copyFileSync(join(bundle, "setup", built), join(out, setup));
copyFileSync(join(bundle, "nsis", built), join(out, update));
copyFileSync(join(bundle, "nsis", `${built}.sig`), join(out, `${update}.sig`));
const signature = readFileSync(join(out, `${update}.sig`), "utf8").trim();
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: "",
Expand All @@ -54,14 +63,45 @@ function stage() {
},
},
};
writeFileSync(join(out, "latest.json"), `${JSON.stringify(feed, null, 2)}\n`);
const files = [setup, update, `${update}.sig`, "latest.json"].map((name) => join(out, name));
console.log(`staged in ${out}:\n ${files.join("\n ")}`);
return { version, files };
writeFileSync(join(staged, "latest.json"), `${JSON.stringify(feed, null, 2)}\n`);
console.log(`staged in ${staged}`);
for (const name of [setup, update]) {
console.log(` ${sha256(join(staged, name))} ${name}`);
}
}

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]
.map((name) => `| \`${name}\` | \`${sha256(join(staged, name))}\` |`)
.join("\n");
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/):

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

function publish() {
const { version, files } = stage();
const version = workspaceVersion();
const { setup, update } = names(version);
const files = [setup, update, `${update}.sig`, "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 ")}`);
}
execFileSync(
"gh",
[
Expand All @@ -72,6 +112,9 @@ function publish() {
"--draft",
"--title",
`Botloft ${version}`,
// GitHub puts these notes before the ones it generates.
"--notes",
verifyNotes(version),
"--generate-notes",
],
{ stdio: "inherit" },
Expand Down
5 changes: 3 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,8 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a
- Com versão nova, aparece "Update available" na barra de título. Um clique abre o diálogo: a versão, "What's new" (se o release tiver notas) e o aviso de que o Botloft fecha, instala e abre de novo, e de que os bots pausam por um instante e continuam de onde pararam. "Update now" baixa com progresso e roda o instalador; se falhar, o erro fica em "Details" e dá para tentar de novo.
- O feed aponta para o instalador NSIS do release, `Botloft_<versão>_x64-update.exe`, e não para a tela de instalação (15.7). O instalador roda como `/P /UPDATE /R`: passivo, sem perguntas, e reabre o app. O hook de desinstalação não faz nada com `/UPDATE` (15.4), o daemon segue rodando, e o app reaberto o atualiza porque ele ficou `outdated` (15.2).
- Os artefatos são assinados com a chave do updater do Tauri (`createUpdaterArtifacts`), e a chave pública fica no `tauri.conf.json`. `requireSignedVersion` exige que a assinatura traga a versão, para um feed adulterado não empurrar uma versão antiga de volta. O instalador não tem assinatura Authenticode, então o SmartScreen avisa na primeira execução.
- Release: a versão fica só no `[workspace.package]` do `Cargo.toml` (o `tauri.conf.json` não repete a versão e usa a do crate). Um push de tag `vX.Y.Z` roda `.github/workflows/release.yml`, que confere tag e versão, roda `pnpm bundle` com `TAURI_SIGNING_PRIVATE_KEY` (segredo do repositório), depois `pnpm bundle:setup` (15.7), e abre um release **rascunho** com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) e o `.sig` dele, e o `latest.json` (`app/scripts/release.mjs`). O updater só enxerga o release depois que o dono o publica.
- Release: a versão fica só no `[workspace.package]` do `Cargo.toml` (o `tauri.conf.json` não repete a versão e usa a do crate). Um push de tag `vX.Y.Z` roda `.github/workflows/release.yml`, que confere tag e versão, roda `pnpm bundle` com `TAURI_SIGNING_PRIVATE_KEY` (segredo do repositório), depois `pnpm bundle:setup` (15.7), `release.mjs stage`, o atestado de origem e `release.mjs publish`, que abre um release **rascunho** com a tela de instalação (`-setup.exe`), o instalador do atualizador (`-update.exe`) e o `.sig` dele, e o `latest.json`. O updater só enxerga o release depois que o dono o publica.
- Atestado de origem: entre o `stage` e o `publish`, o workflow roda `actions/attest@v4` nos dois `.exe` (permissões `id-token`, `attestations` e `artifact-metadata`). É um atestado de proveniência SLSA assinado pelo Sigstore: qualquer pessoa confere com `gh attestation verify <arquivo> --repo httpsphl/botloft` que o instalador saiu deste workflow, deste repositório e daquele commit. As notas do rascunho começam com "Verify your download": o SHA-256 dos dois instaladores e esse comando. O VirusTotal fica de fora das notas enquanto a tela de instalação tiver alertas de aprendizado de máquina (na 0.6.0, 2 de 71: Microsoft `Wacatac.C!ml` e Trapmine), à espera da análise de falso positivo.

### 15.6 Idiomas

Expand Down Expand Up @@ -757,7 +758,7 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a
**Build e release.**

- `pnpm bundle:setup` (em `app/`, `scripts/setup.mjs`) roda `vite build --mode setup`, que gera só a `setup.html` em `app/dist-setup`, e `cargo build -p botloft-setup --release --features tauri/custom-protocol` com `BOTLOFT_SETUP_PAYLOAD` apontando para o instalador NSIS que o `pnpm bundle` acabou de gerar. O resultado vai para `target\release\bundle\setup\Botloft_<versão>_x64-setup.exe`. Com `-- --rehearse`, sai sem o instalador dentro. O setup não usa o CLI do Tauri, porque não precisa de bundle: basta o executável.
- O release (15.5) passa a ter a tela de instalação como `Botloft_<versão>_x64-setup.exe`, o nome que o README já indica, e o instalador NSIS como `Botloft_<versão>_x64-update.exe` com o `.sig`, que é para onde o `latest.json` aponta. O `release.mjs stage` copia os dois com esses nomes para `target\release\bundle\release\` e escreve o `latest.json`; o `publish` faz o `stage` e sobe o que ficou lá. O `stage` roda sozinho para conferir os arquivos sem criar release.
- O release (15.5) passa a ter a tela de instalação como `Botloft_<versão>_x64-setup.exe`, o nome que o README já indica, e o instalador NSIS como `Botloft_<versão>_x64-update.exe` com o `.sig`, que é para onde o `latest.json` aponta. O `release.mjs stage` copia os dois com esses nomes para `target\release\bundle\release\`, escreve o `latest.json` e mostra os SHA-256; o `publish` sobe o que o `stage` deixou lá e se recusa sem ele. O `stage` roda sozinho para conferir os arquivos sem criar release.
- O CI do app também roda `vite build --mode setup`, para a página do setup não quebrar só na hora do release.
- O setup não tem assinatura Authenticode, como o NSIS (15.5): o SmartScreen avisa ao abrir. O NSIS gravado na pasta temporária não tem a marca da internet e não passa de novo pelo SmartScreen.
- O executável fica maior, com a janela mais o NSIS inteiro: 15,4 MB na 0.5.0, contra 6,8 MB do NSIS sozinho.
Expand Down
Loading