Skip to content

Commit 1c0bbba

Browse files
authored
Merge pull request #50 from httpsphl/feat/release-provenance
feat(release): attest where the installers come from, and list their SHA-256
2 parents 430fdfb + b6600d2 commit 1c0bbba

4 files changed

Lines changed: 76 additions & 21 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ on:
99

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

1318
env:
1419
CARGO_TERM_COLOR: always
@@ -41,6 +46,12 @@ jobs:
4146
- name: Build the setup window around it
4247
run: pnpm bundle:setup
4348
working-directory: app
49+
- name: Stage the release files
50+
run: node app/scripts/release.mjs stage
51+
- name: Attest where the installers come from
52+
uses: actions/attest@v4
53+
with:
54+
subject-path: target/release/bundle/release/*.exe
4455
- name: Open a draft release
4556
run: node app/scripts/release.mjs publish
4657
env:

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ pnpm bundle # instalador NSIS com o sidecar (spec 15.4), em target\release
4646
pnpm bundle:setup # tela de instalação com esse NSIS dentro (spec 15.7), em target\release\bundle\setup
4747
```
4848

49-
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.
49+
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.
5050

5151
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á.
5252

‎app/scripts/release.mjs‎

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,34 @@
33
// node app/scripts/release.mjs check the tag matches the workspace version
44
// node app/scripts/release.mjs stage the release files, with their final names,
55
// in target/release/bundle/release
6-
// node app/scripts/release.mjs publish stage, then a draft GitHub release with them
6+
// node app/scripts/release.mjs publish a draft GitHub release with what `stage` left
77
//
88
// A release carries two installers: the setup window people download
99
// (`-setup.exe`, from `pnpm bundle:setup`) and the NSIS installer the in-app
1010
// updater runs (`-update.exe`, from `pnpm bundle`, with its signature).
1111
// latest.json, the feed the updater reads from the latest published release,
1212
// points to the second. A draft is invisible to it until the owner publishes
13-
// the release.
13+
// the release. Between `stage` and `publish`, the workflow attests where the
14+
// installers come from; the draft's notes open with their SHA-256 and how to
15+
// check that attestation.
1416

1517
import { execFileSync } from "node:child_process";
16-
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
18+
import { createHash } from "node:crypto";
19+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1720
import { join } from "node:path";
1821
import { root, workspaceVersion } from "./workspace.mjs";
1922

2023
const tag = process.env.GITHUB_REF_NAME ?? "";
2124
const repo = process.env.GITHUB_REPOSITORY ?? "";
25+
const bundle = join(process.env.CARGO_TARGET_DIR ?? join(root, "target"), "release", "bundle");
26+
const staged = join(bundle, "release");
27+
28+
function names(version) {
29+
return {
30+
setup: `Botloft_${version}_x64-setup.exe`,
31+
update: `Botloft_${version}_x64-update.exe`,
32+
};
33+
}
2234

2335
function check() {
2436
const version = workspaceVersion();
@@ -31,18 +43,15 @@ function check() {
3143
/** Copies the release files under their final names and writes latest.json. */
3244
function stage() {
3345
const version = workspaceVersion();
34-
const bundle = join(process.env.CARGO_TARGET_DIR ?? join(root, "target"), "release", "bundle");
3546
const built = `Botloft_${version}_x64-setup.exe`;
36-
const setup = `Botloft_${version}_x64-setup.exe`;
37-
const update = `Botloft_${version}_x64-update.exe`;
38-
const out = join(bundle, "release");
39-
rmSync(out, { recursive: true, force: true });
40-
mkdirSync(out, { recursive: true });
47+
const { setup, update } = names(version);
48+
rmSync(staged, { recursive: true, force: true });
49+
mkdirSync(staged, { recursive: true });
4150

42-
copyFileSync(join(bundle, "setup", built), join(out, setup));
43-
copyFileSync(join(bundle, "nsis", built), join(out, update));
44-
copyFileSync(join(bundle, "nsis", `${built}.sig`), join(out, `${update}.sig`));
45-
const signature = readFileSync(join(out, `${update}.sig`), "utf8").trim();
51+
copyFileSync(join(bundle, "setup", built), join(staged, setup));
52+
copyFileSync(join(bundle, "nsis", built), join(staged, update));
53+
copyFileSync(join(bundle, "nsis", `${built}.sig`), join(staged, `${update}.sig`));
54+
const signature = readFileSync(join(staged, `${update}.sig`), "utf8").trim();
4655
const feed = {
4756
version,
4857
notes: "",
@@ -54,14 +63,45 @@ function stage() {
5463
},
5564
},
5665
};
57-
writeFileSync(join(out, "latest.json"), `${JSON.stringify(feed, null, 2)}\n`);
58-
const files = [setup, update, `${update}.sig`, "latest.json"].map((name) => join(out, name));
59-
console.log(`staged in ${out}:\n ${files.join("\n ")}`);
60-
return { version, files };
66+
writeFileSync(join(staged, "latest.json"), `${JSON.stringify(feed, null, 2)}\n`);
67+
console.log(`staged in ${staged}`);
68+
for (const name of [setup, update]) {
69+
console.log(` ${sha256(join(staged, name))} ${name}`);
70+
}
71+
}
72+
73+
function sha256(path) {
74+
return createHash("sha256").update(readFileSync(path)).digest("hex");
75+
}
76+
77+
/** The start of the release notes: how to check a download. */
78+
function verifyNotes(version) {
79+
const { setup, update } = names(version);
80+
const rows = [setup, update]
81+
.map((name) => `| \`${name}\` | \`${sha256(join(staged, name))}\` |`)
82+
.join("\n");
83+
return `### Verify your download
84+
85+
| File | SHA-256 |
86+
|---|---|
87+
${rows}
88+
89+
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/):
90+
91+
\`\`\`
92+
gh attestation verify ${setup} --repo ${repo}
93+
\`\`\`
94+
`;
6195
}
6296

6397
function publish() {
64-
const { version, files } = stage();
98+
const version = workspaceVersion();
99+
const { setup, update } = names(version);
100+
const files = [setup, update, `${update}.sig`, "latest.json"].map((name) => join(staged, name));
101+
const missing = files.filter((file) => !existsSync(file));
102+
if (missing.length > 0) {
103+
throw new Error(`run \`release.mjs stage\` first; missing:\n ${missing.join("\n ")}`);
104+
}
65105
execFileSync(
66106
"gh",
67107
[
@@ -72,6 +112,9 @@ function publish() {
72112
"--draft",
73113
"--title",
74114
`Botloft ${version}`,
115+
// GitHub puts these notes before the ones it generates.
116+
"--notes",
117+
verifyNotes(version),
75118
"--generate-notes",
76119
],
77120
{ stdio: "inherit" },

‎docs/spec.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -716,7 +716,8 @@ Identidade: o mascote do Botloft é uma chama com olhos. O ícone do app (`app/a
716716
- 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.
717717
- 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).
718718
- 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.
719-
- 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.
719+
- 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.
720+
- 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.
720721

721722
### 15.6 Idiomas
722723

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

759760
- `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.
760-
- 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.
761+
- 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.
761762
- 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.
762763
- 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.
763764
- 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.

0 commit comments

Comments
 (0)