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
39 changes: 15 additions & 24 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,7 @@ jobs:
SOURCE_SHA: ${{ needs.setup.outputs.source_sha }}
REPOSITORY: ${{ needs.setup.outputs.repository }}
PLATFORM: ${{ needs.setup.outputs.windows_platform }}
BERD_RELEASE_CHANNEL: public
BERD_UPDATER_ENDPOINT: https://github.com/${{ needs.setup.outputs.repository }}/releases/download/${{ needs.setup.outputs.rolling_tag }}/latest.json
BERD_UPDATER_PUBLIC_KEY: ${{ secrets.BERD_UPDATER_PUBLIC_KEY }}
BERD_RELEASE_CHANNEL: disabled
# Windows selects generated [script] recipes that Just 1.40 still gates as unstable.
JUST_UNSTABLE: "1"
steps:
Expand Down Expand Up @@ -328,12 +326,7 @@ jobs:
corepack prepare pnpm@10.33.0 --activate
if ((pnpm --version) -ne '10.33.0') { throw 'pnpm 10.33.0 was not activated' }

- name: Require updater public key
shell: pwsh
run: |
if ([string]::IsNullOrWhiteSpace($env:BERD_UPDATER_PUBLIC_KEY)) { throw 'BERD_UPDATER_PUBLIC_KEY repository secret is required' }

- name: Generate updater-enabled Tauri configuration
- name: Generate updater-disabled Tauri configuration
shell: pwsh
run: pnpm tauri:release:config

Expand All @@ -356,6 +349,7 @@ jobs:
- name: Package and sign Windows updater archive
shell: bash
env:
BERD_UPDATER_PUBLIC_KEY: ${{ secrets.BERD_UPDATER_PUBLIC_KEY }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
Expand Down Expand Up @@ -412,9 +406,7 @@ jobs:
SOURCE_SHA: ${{ needs.setup.outputs.source_sha }}
REPOSITORY: ${{ needs.setup.outputs.repository }}
PLATFORM: ${{ needs.setup.outputs.linux_platform }}
BERD_RELEASE_CHANNEL: public
BERD_UPDATER_ENDPOINT: https://github.com/${{ needs.setup.outputs.repository }}/releases/download/${{ needs.setup.outputs.rolling_tag }}/latest.json
BERD_UPDATER_PUBLIC_KEY: ${{ secrets.BERD_UPDATER_PUBLIC_KEY }}
BERD_RELEASE_CHANNEL: disabled
BERD_TAURI_CARGO_TARGET_DIR: ${{ github.workspace }}/src-tauri/target
steps:
- name: Check out verified source
Expand All @@ -440,10 +432,6 @@ jobs:
libayatana-appindicator3-dev \
librsvg2-dev

- name: Require updater public key
run: |
: "${BERD_UPDATER_PUBLIC_KEY:?BERD_UPDATER_PUBLIC_KEY repository secret is required}"

- name: Set up Linux release dependencies and locked signer tooling
run: |
pnpm install --frozen-lockfile
Expand All @@ -464,11 +452,13 @@ jobs:
CARGO_FEATURES="$(scripts/block-feature-gates.sh berdctl)"
BERD_APP_VERSION="$VERSION" \
VITE_APP_VERSION="$VERSION" \
VITE_UPDATER_ENABLED=false \
pnpm tauri build --bundles appimage,deb --features "$CARGO_FEATURES" \
--config src-tauri/tauri.release.conf.json

- name: Package and sign Linux updater archive
env:
BERD_UPDATER_PUBLIC_KEY: ${{ secrets.BERD_UPDATER_PUBLIC_KEY }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
Expand Down Expand Up @@ -513,8 +503,8 @@ jobs:

promote:
name: Approve and promote updater feed
needs: [setup, stage-macos, stage-windows, stage-linux]
if: always() && needs.setup.result == 'success' && (needs.stage-macos.result == 'success' || needs.stage-macos.result == 'skipped') && (needs.stage-windows.result == 'success' || needs.stage-windows.result == 'skipped') && (needs.stage-linux.result == 'success' || needs.stage-linux.result == 'skipped')
needs: [setup, stage-macos]
if: always() && needs.setup.result == 'success' && (needs.stage-macos.result == 'success' || needs.stage-macos.result == 'skipped')
runs-on: macos-latest
timeout-minutes: 30
environment: release
Expand Down Expand Up @@ -564,7 +554,7 @@ jobs:
}
echo "environment_approver=$APPROVER" >> "$GITHUB_ENV"

- name: Verify staged immutable assets for all platforms
- name: Verify staged immutable macOS assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ needs.setup.outputs.repository }}
Expand All @@ -573,10 +563,8 @@ jobs:
EXPECTED_SOURCE_SHA: ${{ needs.setup.outputs.source_sha }}
run: |
set -euo pipefail
for PLATFORM in darwin-aarch64 windows-x86_64 linux-x86_64; do
export PLATFORM
scripts/release/github/verify-versioned-release.sh "$EXPECTED_TAG" "$EXPECTED_SOURCE_SHA"
done
export PLATFORM=darwin-aarch64
scripts/release/github/verify-versioned-release.sh "$EXPECTED_TAG" "$EXPECTED_SOURCE_SHA"

- name: Install locked signer tooling
run: pnpm install --frozen-lockfile
Expand All @@ -588,7 +576,10 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
scripts/release/github/promote-updater.sh "$TAG" "$SOURCE_SHA" "$RUNNER_TEMP/promotion-summary.md"
promotion_config="$RUNNER_TEMP/promotion-channel.json"
jq '.platforms = ["darwin-aarch64"]' "$RELEASE_CHANNEL_CONFIG" > "$promotion_config"
BERD_RELEASE_CHANNEL_CONFIG="$promotion_config" \
scripts/release/github/promote-updater.sh "$TAG" "$SOURCE_SHA" "$RUNNER_TEMP/promotion-summary.md"
cat "$RUNNER_TEMP/promotion-summary.md" >> "$GITHUB_STEP_SUMMARY"

- name: Record workflow and environment approval
Expand Down
32 changes: 22 additions & 10 deletions docs/release-and-updates.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Release and Auto-Update

Berd uses [Tauri's updater plugin](https://v2.tauri.app/plugin/updater/). Release builds use a GitHub release feed and an Ed25519 verification key. Local and custom builds disable updates unless their distributor supplies a complete, trusted updater profile.
Berd uses [Tauri's updater plugin](https://v2.tauri.app/plugin/updater/). The public macOS build uses a GitHub release feed and an Ed25519 verification key. Windows and Linux release artifacts are built and staged with the updater disabled until those platforms are ready for automatic distribution. Local and custom builds also disable updates unless their distributor supplies a complete, trusted updater profile.

The endpoint and verification key form one trust contract. `scripts/release/build-tauri-release-config.mjs` requires an explicit `BERD_RELEASE_CHANNEL`; enabled profiles require both `BERD_UPDATER_ENDPOINT` and `BERD_UPDATER_PUBLIC_KEY`, enforce credential-free HTTPS, and never fall back to another channel. Disabled builds carry no updater endpoint, key, or plugin registration.

## Feed and assets

The release boundary is centralized in `scripts/release/release-channel.json`. The rolling release endpoint is:
The tag-bound release defaults are centralized in `scripts/release/release-channel.json`. The workflow narrows its promotion copy of that configuration to the platforms currently approved for automatic distribution. The rolling release endpoint is:

`https://github.com/block/berd/releases/download/berd-desktop-latest/latest.json`

Expand All @@ -31,7 +31,7 @@ A version `X.Y.Z` publishes architecture-qualified assets for macOS, Windows, an
- `Berd_X.Y.Z_linux-x86_64.AppImage.tar.gz.sig`
- `Berd_X.Y.Z_linux-x86_64.AppImage.tar.gz.sha256`

The updater manifest contains `darwin-aarch64`, `windows-x86_64`, and `linux-x86_64`. Promotion cannot run until all three platform payloads are staged and reverified.
The updater manifest currently contains only `darwin-aarch64`. Windows and Linux artifacts are still attached to the versioned release for manual testing, but are not copied to the rolling updater release and do not gate promotion.

## Release flow

Expand All @@ -42,14 +42,26 @@ Release tags use canonical SemVer without build metadata, such as `v1.2.3` or `v
3. Run `just release-publish X.Y.Z`. It resolves the PR's squash-merge commit, verifies the committed release state, creates an annotated tag on that exact commit, and pushes only `refs/tags/vX.Y.Z`.
4. The workflow verifies that the checkout and canonical remote tag resolve to the same main-reachable commit and that the tag is annotated.
5. It creates or safely resumes an immutable versioned GitHub release using the matching `CHANGELOG.md` section.
6. The platform jobs produce the macOS app/DMG, Windows NSIS installer, and Linux AppImage/deb packages.
6. The independent platform jobs produce the macOS app/DMG, Windows NSIS installer, and Linux AppImage/deb packages. Windows and Linux compile with `BERD_RELEASE_CHANNEL=disabled` so manual test installs do not query a feed that omits their platform.
7. The macOS signing action signs, notarizes, and staples its artifacts. The Windows NSIS installer and Linux packages are published without platform-native code signatures.
8. Each platform produces a minisign-signed updater archive, SHA-256 digest, and attested source-bound provenance receipt. Minisign authenticates the Windows and Linux updater archives even though their enclosed payloads lack platform-native code signatures.
9. Promotion waits for all three platform jobs and approval in the GitHub `release` environment, then re-downloads and verifies every immutable staged artifact. It rejects version downgrades, rejects changed same-version manifests, and rechecks the rolling manifest immediately before publication.
10. The promotion script uploads all three platform payloads and uploads a three-platform `latest.json` last.
9. Promotion waits for macOS staging and approval in the GitHub `release` environment, then re-downloads and verifies the immutable macOS payload. Windows and Linux continue independently and may finish before or after promotion. Promotion rejects version downgrades, rejects changed same-version manifests, and rechecks the rolling manifest immediately before publication.
10. The workflow gives the unchanged tag-bound promotion script a temporary macOS-only channel configuration. The script uploads the macOS payload and uploads `latest.json` last.

Uploading the manifest last keeps installed clients on the previous release if staging or verification fails. Rollback is a new, higher patch release containing reverted code rather than a lower manifest version.

### Re-enabling Windows or Linux auto-update

Do not add a platform to the manifest until its installer behavior and release posture are approved. To re-enable a platform, update `.github/workflows/release.yml` and its release workflow contract test together:

1. Change that platform job's `BERD_RELEASE_CHANNEL` to `public`, restore `BERD_UPDATER_ENDPOINT`, move `BERD_UPDATER_PUBLIC_KEY` from the packaging step back to the job environment, and restore its public-key preflight step. For Linux, also set `VITE_UPDATER_ENABLED=true`; the Windows bundle script derives the renderer gate from `BERD_RELEASE_CHANNEL`.
2. Add the platform job to `promote.needs` and require its result to be `success` or `skipped` in `promote.if`.
3. Verify the platform with `verify-versioned-release.sh` before promotion.
4. Add the platform ID to the temporary `.platforms` array passed to `promote-updater.sh`. Remove the temporary override once all platforms in `scripts/release/release-channel.json` are approved.
5. Exercise installation and update from a prior build on the platform, then confirm the rolling archive and manifest entry are anonymously downloadable.

Keep the updater public key scoped to the packaging step while a platform build is disabled. Packaging still signs and verifies its staged updater archive so the release lane exercises the future promotion artifact without enabling update checks in the application.

The rolling feed must be anonymously downloadable before the first promotion. This is not possible while the release repository is private.

### Manual recovery
Expand All @@ -63,7 +75,7 @@ gh workflow run release.yml \
-f tag=v1.2.3
```

Recovery verifies the selected tag and is source-bound to that immutable tag and commit. A complete platform payload is reused only after its attested receipt is checked during promotion; an incomplete platform payload is deleted as a unit and rebuilt before promotion.
Recovery verifies the selected tag and is source-bound to that immutable tag and commit. A complete platform payload is reused; an incomplete platform payload is deleted as a unit and rebuilt. Promotion reverifies every platform selected for the rolling feed.

## Downstream distributions

Expand Down Expand Up @@ -93,8 +105,8 @@ deletion, and force changes.

- Run `just ci` and the release script tests.
- Verify that tag, checkout SHA, release target, and staged asset digest agree.
- Confirm the updater archives contain `Berd.app`, the Windows NSIS installer, and the Linux AppImage respectively.
- Verify macOS code signing, Gatekeeper, stapling, entitlements, updater signatures for all platforms, and anonymous download. Confirm the expected unsigned-publisher warning for the Windows installer.
- Confirm the staged updater archives contain `Berd.app`, the Windows NSIS installer, and the Linux AppImage respectively.
- Verify macOS code signing, Gatekeeper, stapling, entitlements, updater signatures for all staged platforms, and anonymous download of every promoted platform. Confirm the expected unsigned-publisher warning for the Windows installer.
- Exercise an update from a prior test build.
- Confirm disabled builds do not register or invoke the updater.
- Exercise recovery and failed promotion; the previous manifest must remain active after failure.
Expand All @@ -103,7 +115,7 @@ deletion, and force changes.

| File | Role |
|---|---|
| `scripts/release/release-channel.json` | Repository, rolling tag, and platform boundary |
| `scripts/release/release-channel.json` | Repository, rolling tag, and tag-bound platform defaults |
| `scripts/release/lib.sh` | Release validation, naming, paths, and explicit inputs |
| `scripts/release/version.mjs` | Shared canonical SemVer parsing and comparison |
| `scripts/release/release.mjs` | Lockstep version checks and prepare/publish maintainer commands |
Expand Down
30 changes: 24 additions & 6 deletions scripts/release/tests/release-scripts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@ describe("generate-latest-json", () => {
});

describe("desktop release workflow platform gate", () => {
it("uses one public build profile across all platform lanes", async () => {
it("uses one public product profile across all platform lanes", async () => {
const workflow = parseYaml(
await readFile(join(repo, ".github/workflows/release.yml"), "utf8"),
);
Expand Down Expand Up @@ -755,23 +755,41 @@ describe("desktop release workflow platform gate", () => {
expect(interpolatedSteps).toEqual([]);
});

it("requires macOS, Windows, and Linux staging before promotion", async () => {
it("promotes only macOS while staging every platform", async () => {
const workflow = await readFile(
join(repo, ".github/workflows/release.yml"),
"utf8",
);
expect(workflow).toContain("needs: [setup, stage-macos]");
expect(workflow).not.toContain("needs.stage-windows.result");
expect(workflow).not.toContain("needs.stage-linux.result");
expect(workflow).toContain("export PLATFORM=darwin-aarch64");
expect(workflow).toContain(
"needs: [setup, stage-macos, stage-windows, stage-linux]",
`jq '.platforms = ["darwin-aarch64"]' "$RELEASE_CHANNEL_CONFIG"`,
);
expect(workflow).toContain("needs.stage-windows.result == 'success'");
expect(workflow).toContain("needs.stage-linux.result == 'success'");
expect(workflow).toContain(
"for PLATFORM in darwin-aarch64 windows-x86_64 linux-x86_64",
'BERD_RELEASE_CHANNEL_CONFIG="$promotion_config"',
);
expect(workflow).toContain("Package and sign Windows updater archive");
expect(workflow).toContain("Package and sign Linux updater archive");
expect(workflow).toContain("actions/attest-build-provenance@");
const parsedWorkflow = parseYaml(workflow);
for (const jobName of ["stage-windows", "stage-linux"]) {
const job = parsedWorkflow.jobs[jobName];
expect(job.env.BERD_RELEASE_CHANNEL).toBe("disabled");
expect(job.env.BERD_UPDATER_ENDPOINT).toBeUndefined();
expect(job.env.BERD_UPDATER_PUBLIC_KEY).toBeUndefined();
const packageStep = job.steps.find((step) =>
step.name.startsWith("Package and sign"),
);
expect(packageStep.env.BERD_UPDATER_PUBLIC_KEY).toContain(
"secrets.BERD_UPDATER_PUBLIC_KEY",
);
}
const linuxBuildStep = parsedWorkflow.jobs["stage-linux"].steps.find(
(step) => step.name === "Build Linux packages",
);
expect(linuxBuildStep.run).toContain("VITE_UPDATER_ENABLED=false");
const attestationSteps = Object.values(parsedWorkflow.jobs).flatMap((job) =>
(job.steps ?? []).filter((step) =>
step.uses?.startsWith("actions/attest-build-provenance@"),
Expand Down