diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c09891f..105082eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index 32c5badb..c6f20746 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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á. diff --git a/app/scripts/release.mjs b/app/scripts/release.mjs index a629d2da..0aa6d55f 100644 --- a/app/scripts/release.mjs +++ b/app/scripts/release.mjs @@ -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(); @@ -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: "", @@ -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", [ @@ -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" }, diff --git a/docs/spec.md b/docs/spec.md index 58ed43b6..1be68ac7 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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__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 --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 @@ -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__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__x64-setup.exe`, o nome que o README já indica, e o instalador NSIS como `Botloft__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__x64-setup.exe`, o nome que o README já indica, e o instalador NSIS como `Botloft__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.