diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a9605112..f5373610d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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 @@ -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: | @@ -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 @@ -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 @@ -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: | @@ -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 @@ -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 }} @@ -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 @@ -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 diff --git a/docs/release-and-updates.md b/docs/release-and-updates.md index 0933e3904..b9c672d3f 100644 --- a/docs/release-and-updates.md +++ b/docs/release-and-updates.md @@ -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` @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 | diff --git a/scripts/release/tests/release-scripts.test.mjs b/scripts/release/tests/release-scripts.test.mjs index 8445a6d19..b28216c56 100644 --- a/scripts/release/tests/release-scripts.test.mjs +++ b/scripts/release/tests/release-scripts.test.mjs @@ -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"), ); @@ -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@"),