diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7cec78a8..7a83bf1e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index 55923c2e..786222c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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á. diff --git a/app/scripts/release.mjs b/app/scripts/release.mjs index 828fc82d..ded0404e 100644 --- a/app/scripts/release.mjs +++ b/app/scripts/release.mjs @@ -1,22 +1,33 @@ // 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 this system's release files, with their +// final names, in target/release/bundle/release, +// and .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"; @@ -24,13 +35,7 @@ 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(); @@ -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/` 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 .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}`); } } @@ -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 ` 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", [ @@ -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 |publish"); } diff --git a/app/src-tauri/tauri.linux.conf.json b/app/src-tauri/tauri.linux.conf.json new file mode 100644 index 00000000..0381c690 --- /dev/null +++ b/app/src-tauri/tauri.linux.conf.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "targets": ["deb", "appimage"] + } +} diff --git a/app/src-tauri/tauri.macos.conf.json b/app/src-tauri/tauri.macos.conf.json new file mode 100644 index 00000000..165a99ab --- /dev/null +++ b/app/src-tauri/tauri.macos.conf.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "targets": ["app", "dmg"], + "macOS": { + "signingIdentity": "-", + "minimumSystemVersion": "11.0" + } + } +} diff --git a/docs/spec.md b/docs/spec.md index c75fc359..826b4558 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -854,6 +854,7 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a - Marca do instalador, sem nome de terceiros à vista: o instalador e o desinstalador usam o ícone do app (`icons/icon.ico`); as telas de boas-vindas e de conclusão têm a imagem lateral `windows/installer-sidebar.bmp` (164×314, o mascote e "Botloft" sobre o preto) e as outras telas o cabeçalho `windows/installer-header.bmp` (150×57, à direita, com `MUI_HEADERIMAGE_RIGHT` no arquivo de hooks). As duas imagens saem do `app-icon.svg` sem o quadrado preto. O rodapé mostra o `bundle.copyright` no lugar de "Nullsoft Install System", e o editor é "Botloft" (`bundle.publisher`), em Aplicativos instalados e como empresa nas propriedades do `Botloft.exe` e da tela de instalação. - Editor antigo: até a 0.6.0 o `bundle.publisher` ficava vazio e o Tauri usava `github`, a segunda parte do identificador, o que num programa sem assinatura parece alguém se passando pelo GitHub. O Tauri também dá o nome do editor à chave que guarda a pasta de instalação (`Software\\Botloft`), lida para atualizar na mesma pasta e para o "desinstalar antes de instalar" do assistente. Por isso os hooks adotam a instalação antiga: `BotloftAdoptOldFolder` copia a pasta de `Software\github\Botloft` para `Software\Botloft\Botloft` quando a chave nova não existe e passa a instalar nela, a menos que `/D` tenha escolhido outra. Roda antes da primeira tela (`MUI_CUSTOMFUNCTION_GUIINIT`), o que cobre o assistente e a atualização passiva, e no começo da instalação (`NSIS_HOOK_PREINSTALL`), para o modo silencioso, que não tem telas. Depois de instalar, o `NSIS_HOOK_POSTINSTALL` apaga a chave antiga, e a `Software\github` só se ficar vazia. A tela de instalação (15.7) lê a chave nova e, na falta dela, a antiga. **Verificado** (Windows 11 25H2, 2026-09-30): sobre a 0.6.0 instalada com o editor antigo e com o app aberto, o instalador novo rodado como o atualizador (`/P /UPDATE /R`) terminou em 3 s, criou `Software\Botloft\Botloft` com a pasta que estava na chave antiga, apagou a antiga e a `Software\github` vazia, deixou o editor "Botloft" em Aplicativos instalados e reabriu o app da mesma pasta; o daemon não foi tocado. Os casos de pasta escolhida pelo dono, `/D` e modo silencioso foram testados com um instalador mínimo compilado com o mesmo arquivo de hooks e chaves de teste. - O manifesto do app (`src-tauri/windows/app.manifest`) é o padrão do Tauri (controles comuns v6) mais `longPathAware`. +- **Linux e macOS** (18, item 8): o mesmo `pnpm bundle` gera os pacotes do sistema em que roda. O Tauri junta ao `tauri.conf.json` o `tauri.linux.conf.json` ou o `tauri.macos.conf.json`, que escolhem os pacotes. Linux: `.deb` (o Tauri põe as dependências: WebKitGTK, GTK e o `libayatana-appindicator3-1` do ícone na bandeja) e AppImage, feitos no Ubuntu 22.04 para o AppImage rodar em distribuições mais antigas (ele usa a glibc do sistema em que foi feito). macOS: `.app` e `.dmg` para Apple Silicon, a partir do macOS 11. Sem conta Apple Developer, o app não é notarizado: tem só assinatura ad-hoc (`signingIdentity = "-"`). Sem nenhuma assinatura, o macOS diria que o app está danificado; assinado assim, quem baixa o libera uma vez em Ajustes do Sistema → Privacidade e Segurança → Abrir Mesmo Assim. O sidecar vai dentro do pacote, ao lado do app, como no Windows. A tela de instalação (15.7) é só do Windows. ### 15.5 Atualizações @@ -861,7 +862,7 @@ 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), `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. +- 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`. Um job por sistema (Windows, Ubuntu 22.04 e macOS Apple Silicon) confere tag e versão, roda `pnpm bundle` com `TAURI_SIGNING_PRIVATE_KEY` (segredo do repositório), no Windows também `pnpm bundle:setup` (15.7), depois `release.mjs stage ` e o atestado de origem dos seus arquivos, e os guarda como artefato do workflow. O job final junta os três e roda `release.mjs publish`, que abre um release **rascunho** com tudo e um `latest.json` só. No Windows são a tela de instalação (`-setup.exe`) e o instalador do atualizador (`-update.exe`, com o `.sig`). No Linux, `Botloft__amd64.AppImage` e `.deb`, cada um com `.sig`. No macOS, `Botloft__aarch64.dmg` e o `.app.tar.gz` que o updater instala, com `.sig`. O `latest.json` tem `windows-x86_64`, `linux-x86_64-appimage`, `linux-x86_64-deb` e `darwin-aarch64`: o plugin procura primeiro a chave com o tipo de instalação (`--`), então quem instalou o `.deb` recebe o `.deb` (instalado com `dpkg -i`) e quem usa o AppImage recebe o AppImage. O updater só enxerga o release depois que o dono o publica. Rodado à mão (`workflow_dispatch`), o workflow faz os mesmos arquivos e os deixa como artefatos, sem conferir a tag, sem atestado e sem release. - 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. Terminam com o link da política de assinatura de código, que o README também mostra no passo de download: a SignPath Foundation pede o termo "Code signing policy" na página inicial e nas de download. 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. - Assinatura de código (Authenticode): ainda não há certificado, e sem ele o release sai sem assinatura. O caminho já está pronto: `app/scripts/sign.mjs ` assina com o `signtool` do Windows SDK o certificado que estiver no repositório de certificados da máquina, escolhido pela impressão digital em `BOTLOFT_SIGN_THUMBPRINT` (uma chave em nuvem aparece ali depois que o cliente dela entra na conta), com carimbo de tempo RFC 3161 (`BOTLOFT_SIGN_TIMESTAMP`) e confere a assinatura em seguida. Sem a variável, não faz nada; com ela e sem o certificado, falha, para um release nunca sair sem assinatura por engano. Quem chama: o Tauri, pelo `bundle.windows.signCommand` do `tauri.bundle.conf.json`, para tudo que entra no instalador (o `Botloft.exe`, o sidecar `botloftd.exe`, os plugins do NSIS, o desinstalador e o próprio instalador; ele pula o que já estiver assinado); e o `setup.mjs` para a tela de instalação, que o Tauri não monta. A cópia do daemon em `\bin` é o mesmo arquivo assinado do instalador. O `release.yml` passa as duas variáveis do repositório. O `.sig` do atualizador e o atestado de origem são feitos depois, sobre os arquivos já assinados. - Todo binário leva nome do produto e versão nos metadados, que é o que um serviço de assinatura confere: o app e a tela de instalação pelo `tauri-build`, o instalador pelo NSIS e o `botloftd.exe` por um `VERSIONINFO` no `botloftd.rc` (produto "Botloft", descrição "Botloft background service", versão do crate), preenchido pelo `build.rs`. @@ -908,7 +909,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\`, 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 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 windows` copia os dois com esses nomes para `target\release\bundle\release\`, escreve `windows.json` (os arquivos e a entrada do updater) e mostra os SHA-256; o `publish` junta o que cada sistema deixou lá num `latest.json`, sobe tudo e se recusa sem nada. 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. @@ -967,7 +968,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e 5. Histórico de versões das instruções do bot. 6. Acesso remoto com token por dispositivo (Tailscale). 7. Sinais entre bots disparando rotinas. -8. Suporte Linux/macOS. Em fatias: (1) CI com Ubuntu e macOS para o daemon; (2) runtime do bot (grupo de processos, ambiente, `claude`, navegador); (3) iniciar com o sistema (`systemd --user`, LaunchAgent); (4) app; (5) empacotamento e release. Feitas: 1 a 4 (14.1, 15.2, 15.6). +8. Suporte Linux/macOS. Em fatias: (1) CI com Ubuntu e macOS para o daemon; (2) runtime do bot (grupo de processos, ambiente, `claude`, navegador); (3) iniciar com o sistema (`systemd --user`, LaunchAgent); (4) app; (5) empacotamento e release. Feitas: 1 a 5 (14.1, 15.2, 15.4 a 15.6). O macOS sai sem notarização (sem conta Apple Developer por enquanto). 9. Navegador dos bots, com o dono assistindo ao vivo. Desenho na seção 21. ## 19. Pontos a verificar na versão alvo do Claude Code