diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3dcaedf..617ccc6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,10 +1,12 @@ name: Publish -# Publishes a draft release the Release workflow built: checks its update -# feed again, publishes it (GitHub makes the tag then), moves the feed and, -# for stable, bumps the Homebrew tap. Run it on main for a stable release -# once you have checked the draft; run it on dev to retry a beta or nightly -# whose publish failed. See ADR 0046 and docs/RELEASING.md. +# Publishes a draft release the Release workflow built: checks each +# platform's update feed again, publishes it (GitHub makes the tag then), +# moves the feeds and, for a stable Mac build, bumps the Homebrew tap. Run it +# on main for a stable release once you have checked the draft; run it on dev +# to retry a beta or nightly whose publish failed. Choose one platform to +# publish an urgent fix for it alone. See ADR 0046, ADR 0056 and +# docs/RELEASING.md. on: workflow_dispatch: @@ -13,6 +15,11 @@ on: description: "The draft release to publish, e.g. v0.13.0" required: true type: string + platforms: + description: "Publish for every platform the draft was built for, or one only (the other's files are removed)" + type: choice + options: [built, mac, windows] + default: built # Nothing by default; each job asks for what it needs. permissions: {} @@ -80,13 +87,29 @@ jobs: public="$(gh api "repos/$GITHUB_REPOSITORY" --jq '.private | not')" echo "PUBLIC_REPO=$public" >> "$GITHUB_ENV" - - run: scripts/publish-release.sh "$TAG" + - env: + PLATFORMS: ${{ inputs.platforms }} + run: | + case "$PLATFORMS" in + built) scripts/publish-release.sh "$TAG" ;; + mac) scripts/publish-release.sh "$TAG" darwin ;; + windows) scripts/publish-release.sh "$TAG" windows ;; + *) + echo "::error::Unknown platforms \"$PLATFORMS\"." + exit 1 + ;; + esac - name: Bump the Homebrew tap if: ${{ needs.channel.outputs.channel == 'stable' }} env: TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} run: | + # The tap carries the Mac app, so a Windows-only release leaves it. + if ! gh release view "$TAG" --json assets --jq '.assets[].name' | grep -qx darwin.json; then + echo "::notice::$TAG has no Mac build, so the tap is left as it is." + exit 0 + fi if [[ -z "$TAP_TOKEN" ]]; then echo "::warning::No HOMEBREW_TAP_TOKEN secret, so the tap still names the last version. See docs/RELEASING.md." exit 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23491d6..a5d2517 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,22 @@ name: Release -# Builds a signed, notarized, universal (Apple Silicon + Intel) Mac release -# as a draft GitHub release, with its update feed checked. See ADR 0046 and +# Builds a draft GitHub release with a signed, notarized, universal (Apple +# Silicon + Intel) Mac app and a Windows x64 installer, each with its own +# update feed file checked. See ADR 0046, ADR 0055, ADR 0056 and # docs/RELEASING.md. # # - Stable: a push to main that carries a new version (the release PR). The -# draft waits for a person to check it and run the Publish workflow. +# draft waits for a person to check it and run the Publish workflow, which +# can publish it for one platform only. # - Nightly: every night from dev, only if dev changed since the last one. # - Beta: run by hand from dev ("Run workflow", channel beta). -# Betas and nightlies publish themselves once the feed check passes. +# Betas and nightlies publish themselves once a platform's feed check passes. +# +# The Mac and Windows builds run side by side and each uploads its feed file +# (darwin.json, windows.json) last, once all its checks passed. One failing +# never stops the other: the release is published for the platforms that +# finished, and the failed one removes its files from the draft. A run by hand +# can build one platform only, for an urgent fix. # # Signing secrets are plain repo secrets while the repo is private (the free # plan has nothing else). Once public they move into the `prerelease` @@ -28,6 +36,11 @@ on: type: choice options: [beta, nightly] default: beta + platforms: + description: "Which platforms to build" + type: choice + options: [both, mac, windows] + default: both # Nothing by default; each job asks for what it needs. permissions: {} @@ -51,8 +64,11 @@ jobs: channel: ${{ steps.decide.outputs.channel }} commit: ${{ steps.decide.outputs.commit }} build: ${{ steps.decide.outputs.build }} + mac: ${{ steps.decide.outputs.mac }} + windows: ${{ steps.decide.outputs.windows }} + version: ${{ steps.decide.outputs.version }} + tag: ${{ steps.decide.outputs.tag }} public: ${{ steps.visibility.outputs.public }} - started: ${{ steps.visibility.outputs.started }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -85,7 +101,10 @@ jobs: env: EVENT: ${{ github.event_name }} CHOSEN: ${{ inputs.channel }} + # Only a run by hand chooses; a push to main and the nightly build both. + PLATFORMS: ${{ inputs.platforms || 'both' }} PUBLIC_REPO: ${{ steps.visibility.outputs.public }} + STARTED: ${{ steps.visibility.outputs.started }} run: | case "$EVENT" in push) channel=stable ;; @@ -118,28 +137,47 @@ jobs: echo "::notice::No new commits on dev since the last nightly." build=false fi + # Each build job sets this version with release.mjs set-version. + case "$channel" in + beta) version="$(node scripts/release.mjs beta)" ;; + nightly) version="$(node scripts/release.mjs nightly --date "$STARTED")" ;; + esac fi - echo "build=$build" >> "$GITHUB_OUTPUT" - echo "commit=$commit" >> "$GITHUB_OUTPUT" + case "$PLATFORMS" in + both) mac=true windows=true ;; + mac) mac=true windows=false ;; + windows) mac=false windows=true ;; + *) + echo "::error::Unknown platforms \"$PLATFORMS\"." + exit 1 + ;; + esac + { + echo "build=$build" + echo "commit=$commit" + echo "mac=$mac" + echo "windows=$windows" + echo "version=$version" + echo "tag=v$version" + } >> "$GITHUB_OUTPUT" - build: - name: Build, sign and notarize (${{ needs.prepare.outputs.channel }}) + # The draft both builds upload into, made once so either can run alone. + draft: + name: Make the draft release (${{ needs.prepare.outputs.channel }}) needs: prepare if: ${{ needs.prepare.outputs.build == 'true' }} - runs-on: macos-15 - timeout-minutes: 120 + runs-on: ubuntu-24.04 environment: ${{ needs.prepare.outputs.channel == 'stable' && 'release' || 'prerelease' }} permissions: contents: write # the draft release - id-token: write # build provenance - attestations: write # build provenance outputs: - tag: ${{ steps.version.outputs.tag }} + release-id: ${{ steps.create.outputs.id }} + notes: ${{ steps.notes.outputs.body }} env: CHANNEL: ${{ needs.prepare.outputs.channel }} - BUNDLE: target/universal-apple-darwin/release/bundle GH_TOKEN: ${{ github.token }} - PUBLIC_REPO: ${{ needs.prepare.outputs.public }} + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -149,37 +187,18 @@ jobs: - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 - # The runner's own rustup, with both Mac targets for the universal build. - - run: | - rustup toolchain install stable --profile minimal \ - --target aarch64-apple-darwin --target x86_64-apple-darwin - rustup default stable - run: pnpm install --frozen-lockfile - - name: Set the version - id: version - env: - STARTED: ${{ needs.prepare.outputs.started }} - run: | - case "$CHANNEL" in - stable) version="$(node -p 'require("./package.json").version')" ;; - beta) version="$(node scripts/release.mjs beta --write)" ;; - nightly) version="$(node scripts/release.mjs nightly --date "$STARTED" --write)" ;; - esac - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=v$version" >> "$GITHUB_OUTPUT" - - name: Write the release notes id: notes - env: - VERSION: ${{ steps.version.outputs.version }} run: | - # They become latest.json's notes too, which the update dialog shows. + # They become each feed file's notes too, which the update dialog shows. notes="$(node scripts/release.mjs notes "$VERSION")" if [[ "$CHANNEL" != stable ]]; then notes="A $CHANNEL build from dev at ${GITHUB_SHA:0:7}, for testing."$'\n\n'"$notes" fi - delimiter="notes-$(uuidgen)" + printf '%s\n' "$notes" > "$RUNNER_TEMP/notes.md" + delimiter="notes-$(od -An -N8 -tx1 /dev/urandom | tr -d ' \n')" { echo "body<<$delimiter" echo "$notes" @@ -189,8 +208,6 @@ jobs: # A re-run reuses the version, so a draft a failed run left is replaced. # A published release is never replaced: downloads never change. - name: Clear a draft a failed run left - env: - TAG: ${{ steps.version.outputs.tag }} run: | if draft="$(gh release view "$TAG" --json isDraft --jq .isDraft 2>/dev/null)"; then if [[ "$draft" != true ]]; then @@ -200,6 +217,62 @@ jobs: gh release delete "$TAG" --yes fi + - name: Make the draft + id: create + env: + COMMIT: ${{ needs.prepare.outputs.commit }} + run: | + prerelease=() + if [[ "$CHANNEL" != stable ]]; then + prerelease=(--prerelease) + fi + gh release create "$TAG" --draft "${prerelease[@]}" \ + --title "Kriyon $VERSION" --notes-file "$RUNNER_TEMP/notes.md" --target "$COMMIT" + id="$(gh api "repos/$GITHUB_REPOSITORY/releases" \ + --jq ".[] | select(.draft and .tag_name == \"$TAG\") | .id" | head -n 1)" + if [[ -z "$id" ]]; then + echo "::error::Made the draft for $TAG but can't find its id." + exit 1 + fi + echo "id=$id" >> "$GITHUB_OUTPUT" + + build: + name: Build, sign and notarize for Mac (${{ needs.prepare.outputs.channel }}) + needs: [prepare, draft] + if: ${{ needs.prepare.outputs.mac == 'true' }} + runs-on: macos-15 + timeout-minutes: 120 + environment: ${{ needs.prepare.outputs.channel == 'stable' && 'release' || 'prerelease' }} + permissions: + contents: write # the draft release + id-token: write # build provenance + attestations: write # build provenance + env: + CHANNEL: ${{ needs.prepare.outputs.channel }} + BUNDLE: target/universal-apple-darwin/release/bundle + GH_TOKEN: ${{ github.token }} + PUBLIC_REPO: ${{ needs.prepare.outputs.public }} + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + # The runner's own rustup, with both Mac targets for the universal build. + - run: | + rustup toolchain install stable --profile minimal \ + --target aarch64-apple-darwin --target x86_64-apple-darwin + rustup default stable + - run: pnpm install --frozen-lockfile + + - name: Set the version + run: node scripts/release.mjs set-version "$VERSION" + - name: Write the App Store Connect key env: APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} @@ -207,7 +280,7 @@ jobs: umask 077 printf '%s' "$APPLE_API_KEY_BASE64" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8" - - name: Build and sign the app and upload the draft + - name: Build and sign the app and upload it to the draft id: tauri uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 env: @@ -225,12 +298,9 @@ jobs: tauriScript: pnpm tauri # tauri.updater.conf.json turns on the signed update files. args: --target universal-apple-darwin --config src-tauri/tauri.updater.conf.json - tagName: ${{ steps.version.outputs.tag }} - releaseName: Kriyon ${{ steps.version.outputs.version }} - releaseBody: ${{ steps.notes.outputs.body }} - releaseCommitish: ${{ needs.prepare.outputs.commit }} - releaseDraft: true - prerelease: ${{ needs.prepare.outputs.channel != 'stable' }} + releaseId: ${{ needs.draft.outputs.release-id }} + # The notes of latest.json, which darwin.json is made from. + releaseBody: ${{ needs.draft.outputs.notes }} # Apple notarizes the outermost container only; its ticket covers the # app inside (see scripts/notarize-dmg.sh). @@ -251,7 +321,6 @@ jobs: - name: Check the app and the DMG env: - VERSION: ${{ steps.version.outputs.version }} BUILT: ${{ steps.tauri.outputs.appVersion }} DMG: ${{ steps.dmg.outputs.path }} run: | @@ -267,7 +336,6 @@ jobs: # Homebrew cask and anyone who wants a specific version. - name: Replace the draft's DMG with the notarized one env: - TAG: ${{ steps.version.outputs.tag }} DMG: ${{ steps.dmg.outputs.path }} run: | gh release upload "$TAG" "$DMG" --clobber @@ -295,27 +363,151 @@ jobs: ${{ steps.dmg.outputs.path }} ${{ env.BUNDLE }}/macos/Kriyon.app.tar.gz - - name: Check the update feed - env: - TAG: ${{ steps.version.outputs.tag }} - VERSION: ${{ steps.version.outputs.version }} + # latest.json is what builds up to 0.15.0 read; darwin.json is the same + # entries under the per-platform name newer builds read (ADR 0056). Its + # upload comes last: it is what tells publishing the Mac build is ready. + - name: Check the Mac feed and add darwin.json run: | - gh release download "$TAG" --pattern latest.json --dir "$RUNNER_TEMP" + gh release download "$TAG" --pattern latest.json --dir "$RUNNER_TEMP" --clobber node scripts/publish.mjs check-feed "$RUNNER_TEMP/latest.json" --version "$VERSION" + node scripts/publish.mjs platform-feed "$RUNNER_TEMP/latest.json" --platform darwin \ + > "$RUNNER_TEMP/darwin.json" + node scripts/publish.mjs check-feed "$RUNNER_TEMP/darwin.json" --version "$VERSION" + gh release upload "$TAG" "$RUNNER_TEMP/darwin.json" --clobber + + # A failed or cancelled Mac build takes its files back out, so the + # release can still go out for Windows without a half-made Mac download + # in it. A job that times out may not get here; publishing drops any + # platform without its feed file anyway (publish-release.sh). + - name: Remove the Mac files from the draft + if: ${{ failure() || cancelled() }} + run: scripts/drop-platform.sh "$TAG" darwin - name: Remove the App Store Connect key if: ${{ always() }} run: rm -f "$RUNNER_TEMP/AuthKey.p8" + # Unsigned until Windows code signing is chosen (ADR 0055), so SmartScreen + # warns on install. + build-windows: + name: Build for Windows (${{ needs.prepare.outputs.channel }}) + needs: [prepare, draft] + if: ${{ needs.prepare.outputs.windows == 'true' }} + runs-on: windows-2025 + timeout-minutes: 90 + environment: ${{ needs.prepare.outputs.channel == 'stable' && 'release' || 'prerelease' }} + permissions: + contents: write # the draft release + id-token: write # build provenance + attestations: write # build provenance + defaults: + run: + shell: bash + env: + CHANNEL: ${{ needs.prepare.outputs.channel }} + BUNDLE: target/release/bundle + GH_TOKEN: ${{ github.token }} + PUBLIC_REPO: ${{ needs.prepare.outputs.public }} + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + - run: | + rustup toolchain install stable --profile minimal + rustup default stable + - run: pnpm install --frozen-lockfile + + - name: Set the version + run: node scripts/release.mjs set-version "$VERSION" + + - name: Build the installer and upload it to the draft + id: tauri + uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 + env: + GITHUB_TOKEN: ${{ github.token }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + with: + tauriScript: pnpm tauri + # The NSIS setup.exe only: one installer to download, and the one + # the updater runs. tauri.updater.conf.json turns on its .sig. + args: --bundles nsis --config src-tauri/tauri.updater.conf.json + releaseId: ${{ needs.draft.outputs.release-id }} + # windows.json is written below; latest.json stays the Mac's, for + # builds up to 0.15.0. + uploadUpdaterJson: false + + - name: Find the installer + id: setup + env: + BUILT: ${{ steps.tauri.outputs.appVersion }} + run: | + if [[ "$BUILT" != "$VERSION" ]]; then + echo "::error::Tauri built $BUILT, but this release is $VERSION." + exit 1 + fi + setup="$(node scripts/publish.mjs windows-installer "$BUNDLE"/nsis/*)" + if [[ ! -f "$setup.sig" ]]; then + echo "::error::$setup has no .sig next to it." + exit 1 + fi + echo "path=$setup" >> "$GITHUB_OUTPUT" + + - name: Record build provenance + if: ${{ needs.prepare.outputs.public == 'true' }} + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: ${{ steps.setup.outputs.path }} + + # Its upload comes last: it is what tells publishing the Windows build + # is ready. The installer's URL is its API asset URL, as tauri-action + # writes for the Mac: it serves a draft's file with a token, and the + # published file to the updater, which asks for application/octet-stream. + - name: Write and check windows.json + env: + SETUP: ${{ steps.setup.outputs.path }} + run: | + names="$(gh release view "$TAG" --json assets --jq '.assets[].name')" + name="$(xargs node scripts/publish.mjs windows-installer <<<"$names")" + url="$(gh release view "$TAG" --json assets \ + --jq ".assets[] | select(.name == \"$name\") | .apiUrl")" + gh release view "$TAG" --json body --jq .body > "$RUNNER_TEMP/notes.md" + node scripts/publish.mjs windows-feed --version "$VERSION" \ + --url "$url" \ + --sig "$SETUP.sig" --notes-file "$RUNNER_TEMP/notes.md" \ + > "$RUNNER_TEMP/windows.json" + node scripts/publish.mjs check-feed "$RUNNER_TEMP/windows.json" --version "$VERSION" + gh release upload "$TAG" "$RUNNER_TEMP/windows.json" --clobber + + # A failed or cancelled Windows build takes its files back out, so the + # release can still go out for the Mac without a half-made installer in + # it. A job that times out may not get here; publishing drops any + # platform without its feed file anyway (publish-release.sh). + - name: Remove the Windows files from the draft + if: ${{ failure() || cancelled() }} + run: scripts/drop-platform.sh "$TAG" windows + publish: name: Publish (${{ needs.prepare.outputs.channel }}) - needs: [prepare, build] + needs: [prepare, draft, build, build-windows] # Stable waits for a person: they check the draft, then run publish.yml. - if: ${{ needs.prepare.outputs.channel != 'stable' }} + # Otherwise publish as soon as one platform finished: publish-release.sh + # moves only the feeds of platforms whose build passed. + if: >- + ${{ always() && needs.prepare.outputs.channel != 'stable' + && needs.draft.result == 'success' + && (needs.build.result == 'success' || needs.build-windows.result == 'success') }} runs-on: ubuntu-24.04 environment: prerelease permissions: - contents: write # publish the release and upload the feed + contents: write # publish the release and upload the feeds steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -326,5 +518,5 @@ jobs: - env: GH_TOKEN: ${{ github.token }} PUBLIC_REPO: ${{ needs.prepare.outputs.public }} - TAG: ${{ needs.build.outputs.tag }} + TAG: ${{ needs.prepare.outputs.tag }} run: scripts/publish-release.sh "$TAG" diff --git a/crates/kriyon-core/src/update.rs b/crates/kriyon-core/src/update.rs index b6c15a7..15e64bc 100644 --- a/crates/kriyon-core/src/update.rs +++ b/crates/kriyon-core/src/update.rs @@ -27,17 +27,21 @@ pub enum UpdateChannel { impl UpdateChannel { /// The channel's [Update feed]. Fixed: the feed is a trust decision, so /// nothing from the webview or the environment chooses it. + /// + /// One file per platform (ADR 0056): the updater puts `darwin` or + /// `windows` in place of `{{target}}`, so a release built for one + /// platform moves only that platform's file. #[must_use] pub const fn feed_url(self) -> &'static str { match self { Self::Stable => { - "https://github.com/LeafyCode/kriyon/releases/latest/download/latest.json" + "https://github.com/LeafyCode/kriyon/releases/download/feed-stable/{{target}}.json" } Self::Beta => { - "https://github.com/LeafyCode/kriyon/releases/download/feed-beta/latest.json" + "https://github.com/LeafyCode/kriyon/releases/download/feed-beta/{{target}}.json" } Self::Nightly => { - "https://github.com/LeafyCode/kriyon/releases/download/feed-nightly/latest.json" + "https://github.com/LeafyCode/kriyon/releases/download/feed-nightly/{{target}}.json" } } } @@ -190,6 +194,20 @@ pub fn recheck(started: &UpdatePrefs, now: &UpdatePrefs) -> Option { } } +/// Whether a feed's platform `keys` have an entry the updater can install on +/// `target` (`darwin-aarch64`, `windows-x86_64`…): the target itself, or one of +/// its bundles (`darwin-aarch64-app`, `windows-x86_64-nsis`). A feed without +/// one is no update rather than a failed check, so a feed that lacks this +/// platform never shows the person an error. +pub fn has_platform_entry<'a>(keys: impl IntoIterator, target: &str) -> bool { + keys.into_iter().any(|key| { + key == target + || key + .strip_prefix(target) + .is_some_and(|rest| rest.starts_with('-')) + }) +} + /// Whether `older` can read the data `newer` wrote: the same minor, or the /// one before it, within one major. fn reads_data_of(older: &Version, newer: &Version) -> bool { @@ -216,16 +234,16 @@ mod tests { } #[test] - fn each_channel_reads_its_own_feed() { + fn each_channel_reads_its_own_feed_for_this_platform() { assert!(UpdateChannel::Stable .feed_url() - .ends_with("/releases/latest/download/latest.json")); + .ends_with("/releases/download/feed-stable/{{target}}.json")); assert!(UpdateChannel::Beta .feed_url() - .ends_with("/releases/download/feed-beta/latest.json")); + .ends_with("/releases/download/feed-beta/{{target}}.json")); assert!(UpdateChannel::Nightly .feed_url() - .ends_with("/releases/download/feed-nightly/latest.json")); + .ends_with("/releases/download/feed-nightly/{{target}}.json")); for channel in [ UpdateChannel::Stable, UpdateChannel::Beta, @@ -237,6 +255,20 @@ mod tests { } } + #[test] + fn a_feed_entry_for_this_platform_is_its_target_or_a_bundle_of_it() { + let keys = ["darwin-aarch64", "darwin-aarch64-app", "darwin-x86_64"]; + assert!(has_platform_entry(keys, "darwin-aarch64")); + assert!(has_platform_entry( + ["windows-x86_64-nsis"], + "windows-x86_64" + )); + assert!(!has_platform_entry(keys, "windows-x86_64")); + // A longer arch that merely starts with this one is another platform. + assert!(!has_platform_entry(["linux-armv7l"], "linux-armv7")); + assert!(!has_platform_entry([], "darwin-aarch64")); + } + #[test] fn a_version_names_its_channel_like_the_release_scripts() { let of = |s: &str| UpdateChannel::of_version(&v(s)); diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 29b6da4..b51dfdc 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,7 +1,8 @@ # Releasing Kriyon -How a build gets from `dev` to people's Macs. Why it works this way is in ADR 0045 (versions -and the changelog) and ADR 0046 (builds, publishing and feeds). +How a build gets from `dev` to people's Macs and Windows PCs. Why it works this way is in ADR +0045 (versions and the changelog), ADR 0046 (builds, publishing and feeds) and ADR 0056 (one +feed per platform). | Channel | Built from | When | Who publishes | |---|---|---|---| @@ -9,53 +10,114 @@ and the changelog) and ADR 0046 (builds, publishing and feeds). | Beta `0.13.0-beta.1` | `dev` | Someone runs the Release workflow, channel beta | The workflow, once its checks pass | | Nightly `0.13.0-nightly.20261016.41` | `dev` | Every night at 03:17 UTC, if `dev` changed | The workflow, once its checks pass | -Every build is a universal (Apple Silicon and Intel) Mac app, signed with the Developer ID, -notarized, and checked by `scripts/check-mac-bundle.sh`. It lands as a draft release with its -DMG, the signed update file (`Kriyon.app.tar.gz` and its `.sig`) and `latest.json`. Nothing -reaches anyone until the draft is published, and publishing first checks `latest.json` again. +Each run makes one draft release, then builds the Mac app and the Windows installer side by +side into it: + +- **Mac:** a universal (Apple Silicon and Intel) app, signed with the Developer ID, notarized, + and checked by `scripts/check-mac-bundle.sh`. It adds the DMG, the signed update file + (`Kriyon.app.tar.gz` and its `.sig`), `latest.json` and `darwin.json`. +- **Windows:** an x64 NSIS installer, `Kriyon_0.13.0_x64-setup.exe`, its `.sig` and + `windows.json`. Windows builds are not code-signed yet (ADR 0055), so SmartScreen shows + "Windows protected your PC" until someone clicks More info, then Run anyway. + +Each build checks its feed file and uploads it last, so a `darwin.json` or `windows.json` in the +draft means that platform passed every check. A build that fails takes its files back out of +the draft (`scripts/drop-platform.sh`), and **one platform failing never stops the other**: the +release goes out for the platforms that finished. Nothing reaches anyone until the draft is +published, and publishing first checks each feed file again. The DMG is uploaded twice: once with its version in the name (`Kriyon_0.13.0_universal.dmg`, which the Homebrew cask uses) and once as `Kriyon.dmg`. The website's download button links to `https://github.com/LeafyCode/kriyon/releases/latest/download/Kriyon.dmg`, which always serves -the newest stable release. - -Update feeds, which the in-app updater reads (ADR 0047): - -- Stable: `https://github.com/LeafyCode/kriyon/releases/latest/download/latest.json` -- Beta: `https://github.com/LeafyCode/kriyon/releases/download/feed-beta/latest.json` - (a stable release goes here too, so beta users land on it) -- Nightly: `https://github.com/LeafyCode/kriyon/releases/download/feed-nightly/latest.json` +the newest stable Mac release: only a stable release with a Mac build becomes GitHub's latest +release. Publishing a Windows build puts its installer on its channel's feed release as +`Kriyon-setup.exe`, so each channel's newest Windows installer has a fixed link, for example +`https://github.com/LeafyCode/kriyon/releases/download/feed-beta/Kriyon-setup.exe`. Stable +lands on `feed-beta` too, so that link is the newest of beta and stable. The website's Windows +button uses it, with a BETA tag, while Windows is in beta (`WINDOWS_SETUP_URL` in +`website/src/lib/site.ts`); once Windows ships stable, point it at `feed-stable` and drop the +tag. Never use `releases/latest/download/…` for Windows: a Mac-only release would leave it +without one. + +Update feeds, which the in-app updater reads (ADR 0047, ADR 0056), one file per platform on +each channel's fixed pre-release. The app asks for `{{target}}.json`, which the updater fills in +as `darwin.json` or `windows.json`: + +- Stable: `https://github.com/LeafyCode/kriyon/releases/download/feed-stable/darwin.json` + (and `windows.json`) +- Beta: `…/releases/download/feed-beta/darwin.json` (and `windows.json`). A stable release goes + here too, so beta users land on it. +- Nightly: `…/releases/download/feed-nightly/darwin.json` (and `windows.json`) + +Every publish run also fills in any of these six files that is still missing, so no build ever +reads a file that isn't there, which would show as a failed check. A missing `darwin.json` is +made from that channel's `latest.json`; the first run puts 0.15.0 on `feed-stable`. A missing +`windows.json` gets the same version with no entries, which the app reads as no update, until +the first Windows build for that channel replaces it. + +Builds up to 0.15.0 read the older single `latest.json`, which carries the Mac entries only: +stable's from `releases/latest/download/latest.json`, beta's and nightly's from +`feed-beta/latest.json` and `feed-nightly/latest.json`. Publishing still writes those for every +Mac build, so those Macs keep updating, and their first update moves them to the new feeds. +Stop writing them (and delete them from the feed releases) once nobody runs 0.15.0 or older; +until then they cost nothing. From the fourth stable release after 0.15.0, Publish leaves a +warning in its run summary as a reminder to check. + +### Versions when only one platform ships + +Mac and Windows share one version sequence. A release that ships for one platform only uses up +its number on the other: if 0.16.1 goes out for the Mac alone, Windows goes from 0.16.0 to +0.16.2. Say "macOS only" or "Windows only" at the top of that version's changelog entry, so +nobody looks for the other download. ## Ship a stable release 1. Cut it with the `release` skill or by hand: `pnpm release` from an up-to-date `dev`, PR into `dev`, then the release PR from `dev` into `main`. -2. The merge starts **Release**. It refuses if `main` took a user-facing change the - changelog entry doesn't list: add it to the entry in a `chore(release): ` PR into - `dev`, then take `dev` into `main` again. Otherwise it leaves a draft `v`. -3. Check the draft on GitHub: notes, files, and if you like, install the DMG. You can edit the - notes there. The in-app update dialog shows the notes from `latest.json`, which came from - `CHANGELOG.md`, so fix big mistakes in the changelog, not only on the draft. To change the - version, delete the draft and cut again: the version is built into the app. +2. The merge starts **Release**, which builds both platforms. It refuses if `main` took a + user-facing change the changelog entry doesn't list: add it to the entry in a + `chore(release): ` PR into `dev`, then take `dev` into `main` again. Otherwise it + leaves a draft `v`. +3. Check the draft on GitHub: notes, files, and if you like, install the DMG or the setup.exe. + You can edit the notes there. The in-app update dialog shows the notes from the feed files, + which came from `CHANGELOG.md`, so fix big mistakes in the changelog, not only on the draft. + To change the version, delete the draft and cut again: the version is built into the app. 4. Run **Publish** on `main` with the tag: - `gh workflow run publish.yml --ref main -f tag=v0.13.0`. It checks the feed, publishes - (GitHub makes the tag), puts the release on the beta feed and bumps the Homebrew tap. + `gh workflow run publish.yml --ref main -f tag=v0.13.0`. It checks each feed file, + publishes (GitHub makes the tag), moves the stable and beta feeds, puts the Windows installer on + `feed-stable/Kriyon-setup.exe` and `feed-beta/Kriyon-setup.exe` and, for a Mac build, bumps the Homebrew tap. + +## Ship an urgent fix for one platform + +- **Stable:** cut and merge the release as usual; Release builds both. Then publish only the + one that needs it: `gh workflow run publish.yml --ref main -f tag=v0.13.1 -f platforms=mac` + (or `windows`). The other platform's files are removed from the draft first, so its users + are not offered a version that changes nothing for them. If the other platform's build + failed, its files are already gone and `platforms=built` publishes what finished. +- **Beta or nightly:** build one platform only: + `gh workflow run release.yml --ref dev -f channel=beta -f platforms=mac` (or `windows`). ## Betas and nightlies - Beta: `gh workflow run release.yml --ref dev -f channel=beta`, or Actions → Release → Run - workflow. It numbers itself (`x.y.z-beta.N`). + workflow. It numbers itself (`x.y.z-beta.N`) and builds both platforms unless you pass + `-f platforms=mac` or `windows`. - Nightly: runs on its own every night when `dev` changed; start one by hand with `-f channel=nightly`. Only the newest 7 are kept. ## When something fails -- **A build failed.** Re-run it. A re-run gets the same version, even a nightly re-run after - midnight (its date is the run's start), deletes the draft the failed run left and builds - again. A published release is never replaced. -- **The feed check failed.** Nothing was published and no feed moved. Read the step's output: - it names the platform and the reason (missing entry, wrong key, wrong version). Fix the cause, - then re-run. +- **One platform's build failed, timed out or was cancelled.** Its files come out of the draft: + the build removes them when it can, and publishing drops any platform without its feed file. A beta or nightly + has gone out for the other platform; the failed one catches up with the next build. For + stable, either publish what finished (`platforms=built`) or re-run the whole workflow. +- **A build failed and you want it again.** Re-run the whole workflow, not only the failed job: + a re-run gets the same version, even a nightly re-run after midnight (its date is the run's + start), deletes the draft the failed run left and builds again. A published release is never + replaced. +- **A feed check failed.** That platform's build failed, so the line above applies. Read the + step's output: it names the platform and the reason (missing entry, another platform's entry, + wrong key, wrong version). Fix the cause, then re-run. - **A publish failed.** Re-run it, or run **Publish** with the tag (on `dev` for a beta or nightly, on `main` for stable). It is safe to run again: once a release is published it skips that step and finishes the rest (the feed, old nightlies, the Homebrew tap). Feeds and the @@ -87,7 +149,7 @@ touch your installed Kriyon, its sessions or `~/.claude/settings.json`. Check the new `.sig`: `base64 -d < KriyonUpdateTest.app.tar.gz.sig` must show `version:0.12.1` in its trusted comment, or the app refuses it. -3. In a folder with the new `KriyonUpdateTest.app.tar.gz`, write `latest.json` with `version`, +3. In a folder with the new `KriyonUpdateTest.app.tar.gz`, write `darwin.json` with `version`, `notes` and, under `platforms`, `darwin-aarch64-app` and `darwin-aarch64` (`x86_64` on Intel), each with the `.sig` file's contents as `signature` and `http://127.0.0.1:8765/KriyonUpdateTest.app.tar.gz` as `url`. Serve it: @@ -95,11 +157,11 @@ touch your installed Kriyon, its sessions or `~/.claude/settings.json`. 4. Copy the old app somewhere writable and start it: ```sh - HOME=/tmp/kriyon-update-home KRIYON_UPDATE_FEED=http://127.0.0.1:8765/latest.json \ + HOME=/tmp/kriyon-update-home KRIYON_UPDATE_FEED=http://127.0.0.1:8765/darwin.json \ KriyonUpdateTest.app/Contents/MacOS/kriyon ``` - About 5 seconds in it fetches `latest.json`. Pass the welcome screen, open the + About 5 seconds in it fetches `darwin.json`. Pass the welcome screen, open the **Update available** pill, and choose **Install & restart**. The app downloads the file, restarts, and its `Info.plist` then says the new version. diff --git a/docs/adr/0045-one-version-and-a-changelog-from-commits.md b/docs/adr/0045-one-version-and-a-changelog-from-commits.md index 2547f96..436a2ce 100644 --- a/docs/adr/0045-one-version-and-a-changelog-from-commits.md +++ b/docs/adr/0045-one-version-and-a-changelog-from-commits.md @@ -55,8 +55,8 @@ App; and it opens its own release pull request on `main`, which ADR 0026 doesn't - **Pre-releases use the same next version**: `-beta.`, N one past the highest existing beta tag of that version, and `-nightly..`, with the UTC date and the CI run number. When no commit calls for a bump, the base is the next patch. - Pre-release tags never close a changelog section or move the base. `--write` sets the - version in the working tree for a build and commits nothing. + Pre-release tags never close a changelog section or move the base. A build sets the + version in its working tree and commits nothing (now `release.mjs set-version`, ADR 0056). - **v0.12.0 is the first tag**, on `3e8aaa7`, the merge that took the 0.12.0 release into `main`. It predates the merge-base rule, and `dev` already holds that commit, so git-cliff on `dev` counts from it all the same. Its `package.json` and `CHANGELOG.md` say 0.12.0. @@ -72,8 +72,9 @@ App; and it opens its own release pull request on `main`, which ADR 0026 doesn't - git-cliff is a pinned dev dependency, so the same version runs locally and in CI. - The `release` skill walks an agent through the same steps and stops for the person's yes before every push, pull request and tag. -- Phase 6 builds on this (ADR 0046): the release workflow runs the tag checks, and the beta - and nightly jobs call `node scripts/release.mjs beta --write` or `… nightly --write`, which print only the - version on stdout (`pnpm release` adds a banner of its own). They set `Cargo.lock` by - text, so they need no Rust toolchain, but they do need the full git history and tags - (`fetch-depth: 0`) to count betas and find the base. +- Phase 6 builds on this (ADR 0046): the release workflow runs the tag checks and works out + the version once with `node scripts/release.mjs beta` or `… nightly`, which print only the + version on stdout (`pnpm release` adds a banner of its own). That needs the full git history + and tags (`fetch-depth: 0`) to count betas and find the base. Each build job then sets it + with `release.mjs set-version`, which sets `Cargo.lock` by text, so it needs no Rust + toolchain. diff --git a/docs/adr/0046-release-builds-and-update-feeds.md b/docs/adr/0046-release-builds-and-update-feeds.md index 649ae77..53f0a91 100644 --- a/docs/adr/0046-release-builds-and-update-feeds.md +++ b/docs/adr/0046-release-builds-and-update-feeds.md @@ -55,7 +55,8 @@ attestations until then, and macOS runners cost ten times the Linux rate. Nobody pushes a tag by hand any more; `pnpm release tag` is gone. - **Betas and nightlies publish themselves.** A beta runs when someone presses "Run workflow". A nightly runs every night, and skips when a nightly tag already points at `dev`'s head. Both - set their version with `node scripts/release.mjs beta|nightly --write`. Only the newest 7 + get their version from `node scripts/release.mjs beta|nightly`, set in each build job with + `release.mjs set-version`. Only the newest 7 nightlies are kept, newest in version order, so the one the nightly feed names is always kept. - **The feed is checked before it moves.** `scripts/publish-release.sh` runs diff --git a/docs/adr/0055-windows-support.md b/docs/adr/0055-windows-support.md index a38be8d..9cdf1cc 100644 --- a/docs/adr/0055-windows-support.md +++ b/docs/adr/0055-windows-support.md @@ -79,5 +79,8 @@ runs natively on Windows and runs hook commands through Git Bash. - Status hooks on Windows depend on Python being installed. Porting the hook scripts to a small Rust program would remove that dependency; it is the largest remaining Windows gap. -- The release tooling (`scripts/release-mac.sh`, signing, the update feed) is - still Mac-only. A Windows installer builds with `tauri build --bundles nsis`. +- The Release workflow builds the Windows NSIS installer beside the Mac build, + into the same draft release, with its own update feed file, `windows.json` + (ADR 0056, docs/RELEASING.md). It is not + code-signed yet, so SmartScreen warns and Smart App Control can block it; + the signing service is still to be chosen. diff --git a/docs/adr/0056-one-update-feed-per-platform.md b/docs/adr/0056-one-update-feed-per-platform.md new file mode 100644 index 0000000..c2f6d2c --- /dev/null +++ b/docs/adr/0056-one-update-feed-per-platform.md @@ -0,0 +1,66 @@ +# ADR 0056 — One update feed per platform + +**Date:** 2026-11-02 +**Status:** Accepted. Changes the feed layout of ADR 0046 and the endpoints of ADR 0047. + +## Context + +ADR 0046 gave each channel one feed file, `latest.json`, and ADR 0055 added Windows to it. One +file for every platform ties them together: + +- The Tauri updater (`tauri-plugin-updater` 2.14, `updater.rs`) reads the whole file. If its + version is an update but the file has no entry for the running platform, `check()` fails with + `TargetNotFound`, so the person sees a failed check, not "no update". +- So every release had to carry every platform. A Windows build failure blocked a Mac release, + and an urgent fix for one platform could not ship without also building, and offering, the + other. +- The file holds only the newest version. Even with the missing-platform case handled, a + Windows-only 0.16.2 would hide a Mac-only 0.16.1 from Macs still on 0.16.0. + +The common practice for desktop updaters is to keep platforms apart: one static feed file per +platform, or an update server asked per platform and architecture. The Tauri updater supports +both through endpoint templating (`{{target}}`, `{{arch}}`, `{{current_version}}`), which its +docs describe for exactly this. + +## Decision + +- **One static feed file per platform per channel**, on that channel's fixed pre-release: + `feed-stable`, `feed-beta` and `feed-nightly`, each holding `darwin.json` and `windows.json`. + The app's endpoint is `…/releases/download/feed-/{{target}}.json`; the updater fills + in `darwin` or `windows`. Still GitHub Releases only: no server to run. +- **Stable gets a fixed `feed-stable` release** like the others, instead of + `releases/latest/download/…`. "Latest release" is one release for every platform, so it + can't follow two platforms that ship different versions. +- **A release can be built or published for one platform.** Each build uploads its feed file + last, once all its checks pass, and a failed build removes its files from the draft. Betas + and nightlies publish for whichever platforms finished; a run by hand can build one platform. + Stable builds both, and Publish can publish one, removing the other's files first. +- **The feed check is per platform**: a platform's required entries, every entry complete and + signed for the version, and no other platform's entries. +- **No entry for this platform reads as no update** in the app (`for_this_platform` in + `update_rt.rs`), so a partial or broken feed never shows an error. +- **One version sequence.** A platform-only release uses up its number on the other platform. +- **Old Mac builds keep their feed.** Builds up to 0.15.0 read `latest.json`. Every Mac build + still uploads it to its release, which stays GitHub's latest release only when it has a Mac + build, and publishing copies it to `feed-beta` and `feed-nightly`. Their first update moves + them to the new feeds. No Windows build has shipped with the old endpoint, so Windows has no + legacy file. +- **No feed file is ever missing.** Each publish fills in any channel's missing `darwin.json` + from that channel's `latest.json`, and any missing `windows.json` with the same version and no + entries. A missing file would be a 404, which the updater reports as a failed check, for + example when someone on a new beta switches to stable before the first new stable ships. +- **The Windows download link** is a fixed `Kriyon-setup.exe` on a feed release, put there + whenever a Windows build moves that feed, since `releases/latest` may be a Mac-only release. + The website links to `feed-beta`'s, marked BETA, while Windows is in beta, and to + `feed-stable`'s once it ships stable. + +## Consequences + +- An urgent fix ships for one platform without waiting for, or bothering, the other. +- Two more files per channel to keep in step, and the publish script and its tests grew. +- Version numbers can skip on a platform; the changelog entry says which platform a version is + for. +- `latest.json` is dead weight once no 0.15.0 or older build is left; drop it then. From the + fourth stable release after 0.15.0, Publish warns in its run summary as a reminder. +- A small update server (rollbacks, staged rollouts) stays possible later: the app's endpoint + is the only thing that would change. diff --git a/docs/context/runtime.md b/docs/context/runtime.md index 2cc5fd2..13a1509 100644 --- a/docs/context/runtime.md +++ b/docs/context/runtime.md @@ -171,11 +171,13 @@ Part of the Kriyon glossary. The index and the naming rule are in [`CONTEXT.md`] default), `beta` (from `dev`, run by hand) or `nightly` (from `dev`, every night it changed). A version names its channel: `x.y.z`, `x.y.z-beta.N`, `x.y.z-nightly.YYYYMMDD.N` (ADR 0045, ADR 0046). _Avoid_: track, ring. -- **Update feed** — one channel's `latest.json`: the newest version, its notes, and per platform - the update file's URL and signature. Stable's is the latest GitHub release's; beta's and - nightly's live on the fixed `feed-beta` and `feed-nightly` pre-releases. Checked by - `scripts/publish.mjs check-feed` before it moves (ADR 0046). Not the [Diff feed]. _Avoid_: - manifest, appcast. +- **Update feed** — one channel's file for one platform (`darwin.json`, `windows.json`): the + newest version built for that platform, its notes, and the update file's URL and signature. + Each channel's live on its fixed `feed-stable`, `feed-beta` or `feed-nightly` pre-release, so a + release built for one platform moves only that platform's file (ADR 0056). Checked by + `scripts/publish.mjs check-feed` before it moves (ADR 0046). Builds up to 0.15.0 read the + older single `latest.json`, which still carries the Mac entries. Not the [Diff feed]. + _Avoid_: manifest, appcast. - **Channel step-down** — installing an *older* build from a calmer [Update channel] the user moved to (nightly → beta, beta → stable), which plain "only newer" would never offer, since semver sorts `x.y.z-nightly.…` after `x.y.z-beta.…`. Offered while the running build is noisier than diff --git a/scripts/drop-platform.sh b/scripts/drop-platform.sh new file mode 100755 index 0000000..221d594 --- /dev/null +++ b/scripts/drop-platform.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Remove one platform's files from a draft release (ADR 0056): a build that +# failed takes its half-uploaded files back out, and a release published for +# one platform only drops the other's. Which file belongs to which platform is +# `assetPlatform` in scripts/publish-lib.mjs. Refuses a published release: +# downloads never change once out. +# +# Needs gh with GH_TOKEN (contents: write). +# +# Usage: scripts/drop-platform.sh +set -euo pipefail + +if (( $# != 2 )); then + echo "usage: $0 " >&2 + exit 2 +fi +tag="$1" +platform="$2" +errors="$(mktemp)" +trap 'rm -f "$errors"' EXIT + +# No release at all: nothing to remove. Any other gh failure (network, rate +# limit, auth) stops here, so files are never left behind unnoticed. +if ! draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>"$errors")"; then + if grep -q "release not found" "$errors"; then + echo " ✓ $tag has no release; nothing to remove" >&2 + exit 0 + fi + cat "$errors" >&2 + echo "✗ Couldn't read $tag, so its $platform files may still be in the draft." >&2 + exit 1 +fi +if [[ "$draft" != true ]]; then + echo "✗ $tag is already published; its files stay as they are." >&2 + exit 1 +fi + +gh release view "$tag" --json assets --jq '.assets[].name' \ + | xargs node scripts/publish.mjs platform-assets "$platform" \ + | while read -r name; do + gh release delete-asset "$tag" "$name" --yes + echo " ✓ Removed $name from $tag" >&2 + done diff --git a/scripts/publish-lib.mjs b/scripts/publish-lib.mjs index bc64bb4..d45ee24 100644 --- a/scripts/publish-lib.mjs +++ b/scripts/publish-lib.mjs @@ -10,8 +10,122 @@ import { createHash, createPublicKey, verify } from "node:crypto"; import { channelOf, compareVersions, isNightly } from "./release-lib.mjs"; -/** The feed entries a universal Mac build must have: one per chip. */ -const MAC_PLATFORMS = ["darwin-aarch64", "darwin-x86_64"]; +const SIG_SUFFIX = /\.sig$/; +/** The Windows installer, the NSIS setup.exe, by file name. */ +const WINDOWS_INSTALLER = /-setup\.exe$/; +const MAC_ASSET = + /^(darwin\.json|latest\.json|Kriyon(_.*)?\.dmg|.*\.app\.tar\.gz(\.sig)?)$/; + +/** + * Each platform Kriyon releases for, keyed by the updater's `{{target}}` + * value, which also names its feed file (darwin.json, windows.json): + * + * - `entries`: what its feed file must have (ADR 0056), one per Mac chip for + * the universal build and the Windows x64 installer. A missing entry leaves + * those users on an old version. + * - `uploads`: whether a release file is one its build uploads. A failed + * build removes its own files from the draft by this rule, and a release + * published without a platform drops that platform's. + * + * One table, so a new platform can't have one without the other. + */ +const PLATFORM_RULES = { + darwin: { + entries: ["darwin-aarch64", "darwin-x86_64"], + uploads: (name) => MAC_ASSET.test(name), + }, + windows: { + entries: ["windows-x86_64"], + uploads: (name) => + name === "windows.json" || + WINDOWS_INSTALLER.test(name.replace(SIG_SUFFIX, "")), + }, +}; + +/** + * The platforms Kriyon releases for. The release scripts ask + * `publish.mjs platforms` for this list instead of writing it out. + */ +export const PLATFORMS = Object.keys(PLATFORM_RULES); + +/** The feed file builds up to 0.15.0 read; it carries the Mac entries only. */ +const LEGACY_FEED = "latest.json"; + +const isOf = (key, platform) => + key === platform || key.startsWith(`${platform}-`); + +const requiredEntries = (platform) => { + const required = PLATFORM_RULES[platform]?.entries; + if (!required) { + throw new Error(`"${platform}" is not a platform Kriyon releases for`); + } + return required; +}; + +const PATH_SEPARATOR = /[\\/]/; +const FEED_FILE = /^(.+)\.json$/; + +/** The platform a feed file is for, from its name (`…/windows.json`). */ +export const feedPlatform = (file) => { + const name = file.split(PATH_SEPARATOR).pop(); + if (name === LEGACY_FEED) { + return "darwin"; + } + const platform = FEED_FILE.exec(name)?.[1]; + if (platform && Object.hasOwn(PLATFORM_RULES, platform)) { + return platform; + } + throw new Error(`${file} is not a platform's feed file`); +}; + +/** + * The one Windows installer among `files` (names or paths), as given. The + * build, the feed and the fixed download link all find it by this rule. + */ +export const windowsInstaller = (files) => { + const found = files.filter((file) => + WINDOWS_INSTALLER.test(file.split(PATH_SEPARATOR).pop()) + ); + if (found.length === 0) { + throw new Error("There is no Windows installer (*-setup.exe)"); + } + if (found.length > 1) { + throw new Error( + `There is more than one Windows installer: ${found.join(", ")}` + ); + } + return found[0]; +}; + +/** The platform whose build uploaded the release file `name`, or undefined. */ +export const assetPlatform = (name) => + PLATFORMS.find((platform) => PLATFORM_RULES[platform].uploads(name)); + +/** `feed` with only `platform`'s entries, for that platform's own file. */ +export const platformFeed = (feed, platform) => { + requiredEntries(platform); + return { + ...feed, + platforms: Object.fromEntries( + Object.entries(feed.platforms).filter(([key]) => isOf(key, platform)) + ), + }; +}; + +/** + * The Windows feed for one NSIS setup.exe. Listed as `windows-x86_64-nsis`, + * which the updater looks for first in an NSIS install, and as + * `windows-x86_64`, its fallback: the same entries tauri-action writes. + */ +export const windowsFeed = ({ version, notes, pubDate, url, signature }) => { + const entry = { signature, url }; + return { + version, + notes, + pub_date: pubDate, + platforms: { "windows-x86_64": entry, "windows-x86_64-nsis": entry }, + }; +}; const LEADING_V = /^v/; const SHA256 = /^[0-9a-f]{64}$/; @@ -147,11 +261,14 @@ export const keyProblem = (publicKey, { publicRepo }) => { }; /** - * What's wrong with a parsed latest.json for `version`, as plain sentences; - * empty when nothing is. The updater rejects the whole file when any entry is - * incomplete, so every entry is checked, not only the Mac ones. + * What's wrong with a parsed feed file for `platform` at `version`, as plain + * sentences; empty when nothing is. The updater rejects the whole file when + * any entry is incomplete, so every entry is checked, not only the required + * ones. An entry for another platform is a problem too: each platform's file + * moves on its own, so it must not carry another's. */ -export const feedProblems = (feed, version) => { +export const feedProblems = (feed, version, platform) => { + const required = requiredEntries(platform); const problems = []; if ( typeof feed?.version !== "string" || @@ -164,11 +281,16 @@ export const feedProblems = (feed, version) => { problems.push("The feed has no platforms"); return problems; } - for (const name of MAC_PLATFORMS) { + for (const name of required) { if (!(name in platforms)) { problems.push(`${name} is missing`); } } + for (const name of Object.keys(platforms)) { + if (!isOf(name, platform)) { + problems.push(`${name} belongs in another platform's feed`); + } + } for (const [name, entry] of Object.entries(platforms)) { for (const field of ["url", "signature"]) { if (typeof entry?.[field] !== "string" || !entry[field]) { diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index 945fa78..6d1c0e3 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -1,31 +1,67 @@ #!/usr/bin/env bash -# Publish a draft release and move its channel's update feed. Run by +# Publish a draft release and move its channel's update feeds. Run by # .github/workflows/release.yml (betas and nightlies, straight after the -# build) and publish.yml (stable, when a person runs it). See ADR 0046. +# build) and publish.yml (stable, when a person runs it). See ADR 0046 and +# ADR 0056. # -# 1. Check the draft's latest.json (scripts/publish.mjs check-feed). Nothing -# below runs unless it passes, so no feed ever names a bad file. -# 2. Publish the draft. GitHub makes the tag now, on the draft's commit. -# 3. Move the feed. Stable's feed is GitHub's "latest release" link, which -# moved in step 2; betas and nightlies each have one fixed pre-release -# (feed-beta, feed-nightly) that holds only their latest.json. Stable is -# put on the beta feed too, so beta users land on it. -# 4. Keep only the newest nightlies. +# 1. Find which platforms the release was built for: each build job uploads +# its feed file (darwin.json, windows.json) only once all its checks +# passed, so a file in the draft means that platform is ready. A release +# needs at least one. Given a platform, the draft is published for that +# one only. +# 2. Check each of those feed files (scripts/publish.mjs check-feed), and +# the Mac's latest.json, the feed builds up to 0.15.0 read. Nothing below +# runs unless they pass, so no feed ever names a bad file. Then take +# every other platform's files out of the draft +# (scripts/drop-platform.sh): one not chosen, or one whose build never +# finished, even a build that timed out before it could clean up. Its +# users are not offered this version, and nothing half-made ships. +# 3. Publish the draft. GitHub makes the tag now, on the draft's commit. +# Stable becomes GitHub's "latest release" only when it has a Mac build +# and is the newest stable: older Mac builds read +# releases/latest/download/latest.json, and the website links to +# releases/latest/download/Kriyon.dmg, so a Windows-only release must +# not take that over. +# 4. Move each built platform's feed file on the channel's fixed +# pre-release (feed-stable, feed-beta, feed-nightly), plus the Mac's +# latest.json on feed-beta and feed-nightly for older builds. Stable is +# put on the beta feeds too, so beta users land on it. A Windows build +# also puts its installer on its feed releases as Kriyon-setup.exe, a +# fixed name: the website links to feed-beta's while Windows is in beta, +# and to feed-stable's once it ships stable. +# 5. Fill in any feed file still missing on any channel, so no build ever +# reads a file that isn't there (a failed check). The Mac's comes from +# that channel's latest.json, so it names the Mac's real current +# version; the first run seeds feed-stable/darwin.json with 0.15.0. +# Windows, until its first build reaches the channel, gets the same +# version with no entries, which the app reads as no update. A later +# build replaces a seed like any older feed file. +# 6. Keep only the newest nightlies. +# 7. From the fourth stable release after 0.15.0, warn that latest.json +# (step 4's legacy feed) can probably go (ADR 0056). # -# Safe to run again: a release already published skips step 2, so a run that -# failed after publishing can still finish moving the feed. A feed only moves -# forward: an older release never replaces the version a feed names. +# Safe to run again: a release already published skips step 3, so a run that +# failed after publishing can still finish moving the feeds. A feed only moves +# forward: an older release never replaces the version a feed file names. # # Needs gh with GH_TOKEN (contents: write). # -# Usage: scripts/publish-release.sh +# Usage: scripts/publish-release.sh [darwin|windows] set -euo pipefail -if (( $# != 1 )); then - echo "usage: $0 " >&2 +if (( $# < 1 || $# > 2 )); then + echo "usage: $0 [darwin|windows]" >&2 exit 2 fi tag="$1" +only="${2:-}" +platform_list="$(node scripts/publish.mjs platforms)" +# shellcheck disable=SC2206 # one plain word per platform +all_platforms=($platform_list) +if [[ -n "$only" && " ${all_platforms[*]} " != *" $only "* ]]; then + echo "✗ \"$only\" is not one of: ${all_platforms[*]}" >&2 + exit 2 +fi version="${tag#v}" channel="$(node scripts/publish.mjs channel "$tag")" work="$(mktemp -d)" @@ -33,63 +69,179 @@ trap 'rm -rf "$work"' EXIT # Fails here when there is no release for the tag at all. draft="$(gh release view "$tag" --json isDraft --jq .isDraft)" +assets="$(gh release view "$tag" --json assets --jq '.assets[].name')" + +platforms=() +for platform in "${all_platforms[@]}"; do + if [[ -n "$only" && "$platform" != "$only" ]]; then + continue + fi + if grep -qx "$platform.json" <<<"$assets"; then + platforms+=("$platform") + fi +done +if (( ${#platforms[@]} == 0 )); then + echo "✗ $tag has no platform's feed file (darwin.json or windows.json): no build finished." >&2 + exit 1 +fi +has() { [[ " ${platforms[*]} " == *" $1 "* ]]; } -gh release download "$tag" --pattern latest.json --dir "$work" -node scripts/publish.mjs check-feed "$work/latest.json" --version "$version" +for platform in "${platforms[@]}"; do + gh release download "$tag" --pattern "$platform.json" --dir "$work" + node scripts/publish.mjs check-feed "$work/$platform.json" --version "$version" +done +if has darwin; then + gh release download "$tag" --pattern latest.json --dir "$work" + node scripts/publish.mjs check-feed "$work/latest.json" --version "$version" +fi +echo " ✓ $tag is built for: ${platforms[*]}" >&2 + +# Only now, with the chosen platforms checked: a failed check above leaves the +# draft as it was, so a wrong choice never throws away a good build. +if [[ "$draft" == true ]]; then + for platform in "${all_platforms[@]}"; do + if ! has "$platform"; then + scripts/drop-platform.sh "$tag" "$platform" + fi + done +fi if [[ "$draft" != "true" ]]; then echo " ✓ $tag is already published; finishing the rest" >&2 elif [[ "$channel" == "stable" ]]; then - # GitHub's latest release is the stable feed, and publish.yml bumps the tap - # only for it, so it moves only when this is the newest stable release. - stable="$(gh release list --limit 200 --exclude-drafts --exclude-pre-releases \ - --json tagName --jq '.[].tagName')" - # shellcheck disable=SC2086 # one tag per word - newest="$(node scripts/publish.mjs moves-forward "$tag" $stable)" + newest=false + if has darwin; then + stable="$(gh release list --limit 200 --exclude-drafts --exclude-pre-releases \ + --json tagName --jq '.[].tagName')" + # shellcheck disable=SC2086 # one tag per word + newest="$(node scripts/publish.mjs moves-forward "$tag" $stable)" + fi gh release edit "$tag" --draft=false --latest="$newest" if [[ "$newest" == true ]]; then echo " ✓ Published $tag as the latest release" >&2 else - echo " ✓ Published $tag; a newer stable release stays the latest" >&2 + echo " ✓ Published $tag; the latest release stays on the newest Mac build" >&2 fi else gh release edit "$tag" --draft=false --prerelease --latest=false echo " ✓ Published $tag" >&2 fi -move_feed() { - local feed="feed-$1" - if ! gh release view "$feed" >/dev/null 2>&1; then - gh release create "$feed" --prerelease --latest=false \ +# Make the feed- pre-release if there is none yet. +ensure_feed_release() { + if ! gh release view "feed-$1" >/dev/null 2>&1; then + gh release create "feed-$1" --prerelease --latest=false \ --target "$(git rev-parse HEAD)" \ --title "Kriyon $1 update feed" \ - --notes "Holds only latest.json, the $1 channel's update feed. Downloads are on each version's own release." + --notes "Holds only the $1 channel's update feeds, one file per platform. Downloads are on each version's own release." >&2 fi - # A new feed release has no latest.json yet; any other failure stops here. - assets="$(gh release view "$feed" --json assets --jq '.assets[].name')" - if grep -qx latest.json <<<"$assets"; then - gh release download "$feed" --pattern latest.json --output "$work/current.json" - current="$(jq -er .version "$work/current.json")" +} + +# Put $work/ on the feed- pre-release, unless the copy there names +# a newer version. Sets `moved` to true when it moved. Called directly, never +# inside $(...), where set -e is off and a failed gh call would go unnoticed. +move_feed() { + local feed="feed-$1" file="$2" + ensure_feed_release "$1" + # A new feed release has no files yet; any other failure stops here. + local current_assets + current_assets="$(gh release view "$feed" --json assets --jq '.assets[].name')" + if grep -qx "$file" <<<"$current_assets"; then + gh release download "$feed" --pattern "$file" --output "$work/current-$file" --clobber + local current forward + current="$(jq -er .version "$work/current-$file")" forward="$(node scripts/publish.mjs moves-forward "$version" "$current")" if [[ "$forward" != true ]]; then - echo " ✓ The $1 feed already names a newer version; left as it is" >&2 + echo " ✓ $feed/$file already names a newer version; left as it is" >&2 + moved=false return fi fi - gh release upload "$feed" "$work/latest.json" --clobber - echo " ✓ The $1 feed names $version" >&2 + gh release upload "$feed" "$work/$file" --clobber >&2 + echo " ✓ $feed/$file names $version" >&2 + moved=true } +# The feed releases this channel's builds go on: stable lands on beta too. case "$channel" in - stable) move_feed beta ;; - beta) move_feed beta ;; - nightly) - move_feed nightly - gh release list --limit 200 --exclude-drafts --json tagName --jq '.[].tagName' \ - | xargs node scripts/publish.mjs stale-nightlies \ - | while read -r old; do - gh release delete "$old" --cleanup-tag --yes - echo " ✓ Deleted the old nightly $old" >&2 - done - ;; + stable) feeds=(stable beta) ;; + beta) feeds=(beta) ;; + nightly) feeds=(nightly) ;; esac + +for feed in "${feeds[@]}"; do + for platform in "${platforms[@]}"; do + move_feed "$feed" "$platform.json" + if [[ "$platform" == windows && "$moved" == true ]]; then + # Fails unless there is exactly one, before anything is downloaded. + setup="$(xargs node scripts/publish.mjs windows-installer <<<"$assets")" + gh release download "$tag" --pattern "$setup" --dir "$work/setup" --clobber + cp "$work/setup/$setup" "$work/Kriyon-setup.exe" + gh release upload "feed-$feed" "$work/Kriyon-setup.exe" --clobber + echo " ✓ feed-$feed/Kriyon-setup.exe is $version's installer" >&2 + fi + done + # Builds up to 0.15.0 read latest.json. Stable's is the latest release's, + # moved above; the others live on their feed release. + if [[ "$feed" != stable ]] && has darwin; then + move_feed "$feed" latest.json + fi +done + +# Fill in each channel's missing feed files from the Mac's latest.json there: +# stable's is the latest release's, the others' are on their feed release. +for feed in stable beta nightly; do + ensure_feed_release "$feed" + present="$(gh release view "feed-$feed" --json assets --jq '.assets[].name')" + missing=() + for platform in "${all_platforms[@]}"; do + if ! grep -qx "$platform.json" <<<"$present"; then + missing+=("$platform") + fi + done + if (( ${#missing[@]} == 0 )); then + continue + fi + seeds="$work/seed-$feed" + mkdir -p "$seeds" + # No tag downloads from GitHub's latest release. + from=() + if [[ "$feed" != stable ]]; then + from=("feed-$feed") + fi + if ! gh release download ${from[@]+"${from[@]}"} --pattern latest.json --dir "$seeds" --clobber; then + echo "::warning::feed-$feed has no latest.json to seed ${missing[*]} from; left missing." >&2 + continue + fi + for platform in "${missing[@]}"; do + node scripts/publish.mjs platform-feed "$seeds/latest.json" --platform "$platform" \ + >"$seeds/$platform.json" + gh release upload "feed-$feed" "$seeds/$platform.json" >&2 + echo " ✓ Seeded feed-$feed/$platform.json from its latest.json" >&2 + done +done + +if [[ "$channel" == nightly ]]; then + gh release list --limit 200 --exclude-drafts --json tagName --jq '.[].tagName' \ + | xargs node scripts/publish.mjs stale-nightlies \ + | while read -r old; do + gh release delete "$old" --cleanup-tag --yes + echo " ✓ Deleted the old nightly $old" >&2 + done +fi + +# latest.json only serves builds up to 0.15.0. Once four stable releases have +# followed it, nag in the run summary to check whether it can go (ADR 0056). +if [[ "$channel" == stable ]]; then + after=0 + for t in $(gh release list --limit 200 --exclude-drafts --exclude-pre-releases \ + --json tagName --jq '.[].tagName'); do + v="${t#v}" + if [[ "$v" != 0.15.0 && "$(printf '%s\n' 0.15.0 "$v" | sort -V | tail -n1)" == "$v" ]]; then + after=$((after + 1)) + fi + done + if (( after >= 4 )); then + echo "::warning::$after stable releases since 0.15.0: time to check whether latest.json (the feed for 0.15.0 and older) can be dropped. See ADR 0056." >&2 + fi +fi diff --git a/scripts/publish.mjs b/scripts/publish.mjs index 5068586..b5056a5 100755 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -2,11 +2,26 @@ // Publishing helpers for the release workflows (.github/workflows/release.yml // and publish.yml). See ADR 0046 and docs/RELEASING.md. // -// node scripts/publish.mjs check-feed --version -// Check an update feed before anyone can get it: both Mac entries are -// there, and each file downloads and is signed by the key in -// src-tauri/tauri.updater.conf.json for that version. Downloads use -// $GH_TOKEN when set, so a draft release's files can be read. +// node scripts/publish.mjs check-feed .json --version +// Check one platform's update feed before anyone can get it (ADR 0056): +// that platform's entries are there and no other's, and each file +// downloads and is signed by the key in src-tauri/tauri.updater.conf.json +// for that version. The file's name says the platform; latest.json, the +// feed builds up to 0.15.0 read, is the Mac's. Downloads use $GH_TOKEN +// when set, so a draft release's files can be read. +// node scripts/publish.mjs platforms +// Print the platforms Kriyon releases for, one per line (darwin, +// windows): the names of their feed files. +// node scripts/publish.mjs windows-installer … +// Print the one Windows installer (*-setup.exe) among them; fail when +// there is none or more than one. +// node scripts/publish.mjs platform-assets … +// Print the release file names that platform's build uploads. +// node scripts/publish.mjs platform-feed --platform +// Print the feed with only that platform's entries. +// node scripts/publish.mjs windows-feed --version --url +// --sig [--notes-file ] +// Print the Windows feed for one NSIS setup.exe. // node scripts/publish.mjs check-key // Check the updater key parses, before a build spends macOS minutes. // @@ -33,13 +48,19 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { + assetPlatform, + feedPlatform, feedProblems, homebrewCask, keyProblem, nightliesNewestFirst, + PLATFORMS, + platformFeed, publicRepoFrom, staleNightlies, verifyUpdateSignature, + windowsFeed, + windowsInstaller, } from "./publish-lib.mjs"; import { channelOf, isNewest } from "./release-lib.mjs"; @@ -86,14 +107,15 @@ const download = async (url) => { const checkFeed = async (feedFile, version) => { if (!(feedFile && version)) { - fail("Usage: check-feed --version "); + fail("Usage: check-feed .json --version "); } + const platform = feedPlatform(feedFile); const feed = JSON.parse(readFileSync(resolve(feedFile), "utf8")); - const problems = feedProblems(feed, version); + const problems = feedProblems(feed, version, platform); if (problems.length > 0) { - fail(`The feed isn't ready:\n - ${problems.join("\n - ")}`); + fail(`The ${platform} feed isn't ready:\n - ${problems.join("\n - ")}`); } - ok(`The feed announces ${version} with both Mac entries`); + ok(`The ${platform} feed announces ${version}`); const publicKey = updaterKey(); // A universal build lists one file under several names; fetch each once. const urls = [ @@ -117,12 +139,52 @@ const checkFeed = async (feedFile, version) => { } }; +const printPlatformAssets = ([platform, ...names]) => { + if (!platform) { + fail("Usage: platform-assets …"); + } + for (const name of names) { + if (assetPlatform(name) === platform) { + console.log(name); + } + } +}; + +const printPlatformFeed = (feedFile, platform) => { + if (!(feedFile && platform)) { + fail("Usage: platform-feed --platform "); + } + const feed = JSON.parse(readFileSync(resolve(feedFile), "utf8")); + console.log(JSON.stringify(platformFeed(feed, platform), null, 2)); +}; + +const printWindowsFeed = (values) => { + if (!(values.version && values.url && values.sig)) { + fail( + "Usage: windows-feed --version --url --sig [--notes-file ]" + ); + } + const notesFile = values["notes-file"]; + const feed = windowsFeed({ + version: values.version, + notes: notesFile ? readFileSync(notesFile, "utf8").trim() : "", + pubDate: new Date().toISOString(), + url: values.url, + signature: readFileSync(values.sig, "utf8").trim(), + }); + console.log(JSON.stringify(feed, null, 2)); +}; + const main = async () => { const { values, positionals } = parseArgs({ allowPositionals: true, options: { version: { type: "string" }, keep: { type: "string", default: String(KEEP_NIGHTLIES) }, + platform: { type: "string" }, + url: { type: "string" }, + sig: { type: "string" }, + "notes-file": { type: "string" }, }, }); const [command, ...args] = positionals; @@ -130,6 +192,25 @@ const main = async () => { case "check-feed": await checkFeed(args[0], values.version); break; + case "platforms": + console.log(PLATFORMS.join("\n")); + break; + case "windows-installer": + try { + console.log(windowsInstaller(args)); + } catch (error) { + fail(error.message); + } + break; + case "platform-assets": + printPlatformAssets(args); + break; + case "platform-feed": + printPlatformFeed(args[0], values.platform); + break; + case "windows-feed": + printWindowsFeed(values); + break; case "check-key": updaterKey(); ok(`The updater key in ${UPDATER_CONFIG} can sign releases here`); diff --git a/scripts/publish.test.mjs b/scripts/publish.test.mjs index c482b9d..52f3e84 100644 --- a/scripts/publish.test.mjs +++ b/scripts/publish.test.mjs @@ -7,16 +7,26 @@ import assert from "node:assert/strict"; import { createHash, generateKeyPairSync, sign } from "node:crypto"; import { describe, test } from "node:test"; import { + assetPlatform, + feedPlatform, feedProblems, homebrewCask, keyProblem, nightliesNewestFirst, + PLATFORMS, + platformFeed, publicRepoFrom, staleNightlies, verifyUpdateSignature, + windowsFeed, + windowsInstaller, } from "./publish-lib.mjs"; +const NO_INSTALLER = /no Windows installer/; +const TWO_INSTALLERS = /more than one Windows installer/; const MENTIONS_MISMATCH = /signature doesn't match the file/; +const MENTIONS_LINUX = /linux/; +const MENTIONS_LINUX_FILE = /linux\.json/; const MENTIONS_BOTH_KEYS = /signed with key 0202020202020202, but the app trusts 0101010101010101/; const MENTIONS_TRUSTED_COMMENT = @@ -245,57 +255,77 @@ describe("the feed file", () => { signature: "c2ln", url: "https://example.com/Kriyon.app.tar.gz", }; - const feed = { + const mac = { version: "0.13.0", notes: "", pub_date: "2026-10-16T00:00:00Z", platforms: { "darwin-aarch64": entry, "darwin-x86_64": entry }, }; + const windows = { + ...mac, + platforms: { "windows-x86_64": entry, "windows-x86_64-nsis": entry }, + }; - test("each Mac chip's entry is required", () => { + test("a Mac feed needs both chips' entries", () => { for (const missing of ["darwin-aarch64", "darwin-x86_64"]) { - const { [missing]: _, ...rest } = feed.platforms; - assert.deepEqual(feedProblems({ ...feed, platforms: rest }, "0.13.0"), [ - `${missing} is missing`, - ]); + const { [missing]: _, ...rest } = mac.platforms; + assert.deepEqual( + feedProblems({ ...mac, platforms: rest }, "0.13.0", "darwin"), + [`${missing} is missing`] + ); } }); - test("a feed with both Mac entries for the expected version has no problems", () => { - assert.deepEqual(feedProblems(feed, "0.13.0"), []); + test("a Windows feed needs the x64 entry", () => { + const { "windows-x86_64": _, ...rest } = windows.platforms; + assert.deepEqual( + feedProblems({ ...windows, platforms: rest }, "0.13.0", "windows"), + ["windows-x86_64 is missing"] + ); + }); + + test("a complete feed for the expected version has no problems", () => { + assert.deepEqual(feedProblems(mac, "0.13.0", "darwin"), []); + assert.deepEqual(feedProblems(windows, "0.13.0", "windows"), []); + }); + + test("a platform's feed holds only that platform, so it can move on its own", () => { + assert.deepEqual( + feedProblems( + { ...mac, platforms: { ...mac.platforms, "windows-x86_64": entry } }, + "0.13.0", + "darwin" + ), + ["windows-x86_64 belongs in another platform's feed"] + ); + }); + + test("a platform the release doesn't know is refused", () => { + assert.throws(() => feedProblems(mac, "0.13.0", "linux"), MENTIONS_LINUX); }); test("a leading v on the feed's version is accepted, as the updater does", () => { assert.deepEqual( - feedProblems({ ...feed, version: "v0.13.0" }, "0.13.0"), + feedProblems({ ...mac, version: "v0.13.0" }, "0.13.0", "darwin"), [] ); }); test("another version is a problem", () => { - assert.deepEqual(feedProblems(feed, "0.13.1"), [ + assert.deepEqual(feedProblems(mac, "0.13.1", "darwin"), [ 'The feed announces "0.13.0", not 0.13.1', ]); }); - test("a missing Mac entry is a problem", () => { - const { "darwin-x86_64": _, ...arm } = feed.platforms; - assert.deepEqual(feedProblems({ ...feed, platforms: arm }, "0.13.0"), [ - "darwin-x86_64 is missing", - ]); - }); - test("an entry without a url or signature is a problem", () => { assert.deepEqual( feedProblems( { - ...feed, - platforms: { - ...feed.platforms, - "darwin-aarch64": { url: entry.url }, - }, + ...mac, + platforms: { ...mac.platforms, "darwin-aarch64": { url: entry.url } }, }, - "0.13.0" + "0.13.0", + "darwin" ), ["darwin-aarch64 has no signature"] ); @@ -305,23 +335,108 @@ describe("the feed file", () => { assert.deepEqual( feedProblems( { - ...feed, + ...mac, platforms: { - ...feed.platforms, + ...mac.platforms, "darwin-aarch64-app": { signature: "c2ln" }, }, }, - "0.13.0" + "0.13.0", + "darwin" ), ["darwin-aarch64-app has no url"] ); }); test("a feed that isn't an object with platforms is a problem", () => { - assert.deepEqual(feedProblems({ version: "0.13.0" }, "0.13.0"), [ + assert.deepEqual(feedProblems({ version: "0.13.0" }, "0.13.0", "darwin"), [ "The feed has no platforms", ]); }); + + test("a feed file's name says its platform; latest.json is the Mac's, as old builds read it", () => { + assert.equal(feedPlatform("darwin.json"), "darwin"); + assert.equal(feedPlatform("/tmp/x/windows.json"), "windows"); + assert.equal(feedPlatform("latest.json"), "darwin"); + assert.throws(() => feedPlatform("linux.json"), MENTIONS_LINUX_FILE); + }); + + test("each release file belongs to the platform that built it", () => { + const darwin = [ + "darwin.json", + "latest.json", + "Kriyon.dmg", + "Kriyon_0.16.0_universal.dmg", + "Kriyon_universal.app.tar.gz", + "Kriyon_universal.app.tar.gz.sig", + ]; + const windows = [ + "windows.json", + "Kriyon_0.16.0_x64-setup.exe", + "Kriyon_0.16.0_x64-setup.exe.sig", + ]; + for (const name of darwin) { + assert.equal(assetPlatform(name), "darwin", name); + } + for (const name of windows) { + assert.equal(assetPlatform(name), "windows", name); + } + assert.equal(assetPlatform("notes.txt"), undefined); + }); + + test("the platforms Kriyon releases for are the feed files' names", () => { + assert.deepEqual(PLATFORMS, ["darwin", "windows"]); + for (const platform of PLATFORMS) { + assert.equal(feedPlatform(`${platform}.json`), platform); + } + }); + + test("the Windows installer is the one setup.exe among the files, by any path", () => { + assert.equal( + windowsInstaller([ + "Kriyon_0.16.0_x64-setup.exe.sig", + "dist/nsis/Kriyon_0.16.0_x64-setup.exe", + "windows.json", + ]), + "dist/nsis/Kriyon_0.16.0_x64-setup.exe" + ); + assert.equal(assetPlatform("Kriyon_0.16.0_x64-setup.exe"), "windows"); + assert.throws(() => windowsInstaller(["Kriyon.dmg"]), NO_INSTALLER); + assert.throws( + () => + windowsInstaller([ + "Kriyon_0.16.0_x64-setup.exe", + "Kriyon_0.16.0_arm64-setup.exe", + ]), + TWO_INSTALLERS + ); + }); + + test("a platform's feed keeps only that platform's entries", () => { + const both = { + ...mac, + platforms: { ...mac.platforms, ...windows.platforms }, + }; + assert.deepEqual(platformFeed(both, "darwin"), mac); + assert.deepEqual(platformFeed(both, "windows"), windows); + }); + + test("a Mac-only feed gives Windows a file with no entries, read as no update", () => { + assert.deepEqual(platformFeed(mac, "windows"), { ...mac, platforms: {} }); + }); + + test("the Windows feed lists the setup.exe under both names the updater looks for", () => { + assert.deepEqual( + windowsFeed({ + version: "0.13.0", + notes: "", + pubDate: "2026-10-16T00:00:00Z", + url: entry.url, + signature: entry.signature, + }), + windows + ); + }); }); describe("pruning nightlies", () => { diff --git a/scripts/release.mjs b/scripts/release.mjs index 82dedcc..ddb0d59 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -9,14 +9,17 @@ // Every check runs before the branch is made. Nothing is pushed. // pnpm release next Print the next release version. // pnpm release draft [x.y.z] Print git-cliff's draft entry. -// pnpm release beta [--write] Print the next beta (x.y.z-beta.N). -// pnpm release nightly [--run N] [--date ] [--write] +// pnpm release beta Print the next beta (x.y.z-beta.N). +// pnpm release nightly [--run N] [--date ] // Print the nightly version (x.y.z-nightly.YYYYMMDD.N). N defaults to // $GITHUB_RUN_NUMBER; the date, the UTC day of --date, to now. CI passes // the run's start time, so a re-run after midnight keeps its version. -// --write sets that version in the working tree for a build; no commit. // Read the version with `node scripts/release.mjs …`: pnpm prints a // banner of its own on stdout. +// node scripts/release.mjs set-version +// Set a version the release workflow already worked out in the working +// tree, for a build; no commit. Every build job of a run sets the same +// one, so they cannot disagree. // node scripts/release.mjs released [--no-fetch] // For the release workflow, once the release PR reaches main: check // that main took what the entry lists and print the commit to tag @@ -279,18 +282,10 @@ const main = () => { fetch: { type: "boolean", default: true }, notes: { type: "string" }, run: { type: "string", default: process.env.GITHUB_RUN_NUMBER }, - write: { type: "boolean", default: false }, }, }); const [command, arg] = positionals; - const printOrWrite = (version) => { - if (values.write) { - setVersion(version); - } - console.log(version); - }; - switch (command) { case "next": console.log(next() ?? "nothing to release"); @@ -301,7 +296,7 @@ const main = () => { ); break; case "beta": - printOrWrite(betaVersion(base(), tags())); + console.log(betaVersion(base(), tags())); break; case "nightly": if (!values.run) { @@ -309,9 +304,12 @@ const main = () => { "A nightly needs a run number. Pass --run N or set GITHUB_RUN_NUMBER." ); } - printOrWrite( - nightlyVersion(base(), nightlyDate(values.date), values.run) - ); + console.log(nightlyVersion(base(), nightlyDate(values.date), values.run)); + break; + case "set-version": + // Throws on anything that isn't a stable, beta or nightly version. + channelOf(arg ?? ""); + setVersion(arg); break; case "released": released(values.fetch); diff --git a/src-tauri/src/update_rt.rs b/src-tauri/src/update_rt.rs index 732cc11..1baf310 100644 --- a/src-tauri/src/update_rt.rs +++ b/src-tauri/src/update_rt.rs @@ -10,7 +10,9 @@ use std::path::{Path, PathBuf}; use std::time::Duration; -use kriyon_core::update::{offer, recheck, CheckKind, Offer, UpdateChannel, UpdatePrefs}; +use kriyon_core::update::{ + has_platform_entry, offer, recheck, CheckKind, Offer, UpdateChannel, UpdatePrefs, +}; use parking_lot::Mutex; use serde::Serialize; use tauri::{Emitter as _, Manager as _}; @@ -236,6 +238,21 @@ fn feed_url(channel: UpdateChannel) -> Result { channel.feed_url().parse().map_err(|e| format!("{e}")) } +/// Whether a feed can update this build at all: a static feed lists this +/// platform, or the server answered for it. The updater would otherwise fail +/// the check with "target not found" when the feed is newer but lacks this +/// platform; that reads as no update instead (ADR 0056). +fn for_this_platform(release: &tauri_plugin_updater::RemoteRelease) -> bool { + match &release.data { + tauri_plugin_updater::RemoteReleaseInner::Dynamic(_) => true, + tauri_plugin_updater::RemoteReleaseInner::Static { platforms } => { + tauri_plugin_updater::target().is_some_and(|target| { + has_platform_entry(platforms.keys().map(String::as_str), &target) + }) + } + } +} + fn now_ms() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -304,7 +321,7 @@ async fn fetch( .map_err(|e| e.to_string())? .timeout(CHECK_TIMEOUT) .version_comparator(move |current, release| { - offer(¤t, &release.version, &rules, kind).is_some() + for_this_platform(&release) && offer(¤t, &release.version, &rules, kind).is_some() }) .build() .map_err(|e| e.to_string())? @@ -510,6 +527,44 @@ mod tests { use super::*; use crate::store::Store; + fn release_for(keys: &[&str]) -> tauri_plugin_updater::RemoteRelease { + let entry = tauri_plugin_updater::ReleaseManifestPlatform { + url: "https://example.com/Kriyon".parse().expect("url"), + signature: "c2ln".to_string(), + }; + tauri_plugin_updater::RemoteRelease { + version: semver::Version::new(9, 0, 0), + notes: None, + pub_date: None, + data: tauri_plugin_updater::RemoteReleaseInner::Static { + platforms: keys + .iter() + .map(|key| ((*key).to_string(), entry.clone())) + .collect(), + }, + } + } + + #[test] + fn a_newer_feed_without_this_platform_is_no_update_not_an_error() { + let target = tauri_plugin_updater::target().expect("a supported platform"); + assert!(for_this_platform(&release_for(&[&target]))); + assert!(for_this_platform(&release_for(&[&format!("{target}-app")]))); + assert!(!for_this_platform(&release_for(&["plan9-mips"]))); + assert!(!for_this_platform(&release_for(&[]))); + } + + /// The file `publish-release.sh` seeds for a platform no build has + /// reached yet: the channel's version with no entries. + #[test] + fn a_seeded_feed_with_no_entries_parses_and_is_no_update() { + let seed: tauri_plugin_updater::RemoteRelease = serde_json::from_str( + r#"{"version":"0.15.0","notes":"","pub_date":"2026-11-01T10:13:24Z","platforms":{}}"#, + ) + .expect("the seeded feed parses"); + assert!(!for_this_platform(&seed)); + } + /// An app with no updater config, as debug builds have: no check ever /// reaches the network. fn mock_app() -> tauri::App { diff --git a/website/src/components/Contribute.tsx b/website/src/components/Contribute.tsx index 02ff0f7..62f9f1b 100644 --- a/website/src/components/Contribute.tsx +++ b/website/src/components/Contribute.tsx @@ -20,8 +20,8 @@ const STARTS = [ n: "02", tone: "amber", href: HELP_WANTED_URL, - title: "Help bring it to Windows", - text: "Mac only for now. Tauri runs on Windows; the terminal and process layer need hands.", + title: "Help test Windows", + text: "Windows is in beta. Try it, report what breaks, or pick up a help-wanted issue.", }, { n: "03", @@ -44,7 +44,7 @@ export function Contribute() { {LEAFYCODE_NAME} {" "} in Sri Lanka, and it gets better with every pull request. Fix a bug, - write a test, sharpen the docs, or help bring it to Windows. + write a test, sharpen the docs, or help test the Windows beta.

diff --git a/website/src/components/Hero.test.tsx b/website/src/components/Hero.test.tsx index 447f07e..bb2ba52 100644 --- a/website/src/components/Hero.test.tsx +++ b/website/src/components/Hero.test.tsx @@ -6,9 +6,10 @@ const LEAFY_CODE_INTERNATIONAL = /LEAFY CODE INTERNATIONAL/; const SANDBOX_CLAIM = /desktop app|sandbox/i; const PLATFORM = /mac|windows|linux/i; const DOWNLOAD = /DOWNLOAD FOR MAC/; +const WINDOWS_BETA = /DOWNLOAD FOR WINDOWS\s*BETA/; test("the headline is the tagline, with no platform in it", () => { - // Kriyon is Mac-only today but not by design; the tagline must outlive that. + // The platforms Kriyon runs on change; the tagline must outlive that. render(); const h1 = screen.getByRole("heading", { level: 1 }); @@ -36,3 +37,12 @@ test("the download button fetches the newest DMG, whatever its version", () => { "https://github.com/LeafyCode/kriyon/releases/latest/download/Kriyon.dmg" ); }); + +test("the Windows button fetches the newest beta installer and says it is beta", () => { + render(); + const download = screen.getByRole("link", { name: WINDOWS_BETA }); + + expect(download.getAttribute("href")).toBe( + "https://github.com/LeafyCode/kriyon/releases/download/feed-beta/Kriyon-setup.exe" + ); +}); diff --git a/website/src/components/Hero.tsx b/website/src/components/Hero.tsx index 9733d0b..10eb8ab 100644 --- a/website/src/components/Hero.tsx +++ b/website/src/components/Hero.tsx @@ -1,5 +1,5 @@ import { sectionLabel } from "../lib/sections.ts"; -import { GITHUB_URL, LATEST_DMG_URL } from "../lib/site.ts"; +import { GITHUB_URL, LATEST_DMG_URL, WINDOWS_SETUP_URL } from "../lib/site.ts"; import { LeafyCredit } from "./LeafyCredit.tsx"; /** The headline, as one sentence for tests and the page title to share. */ @@ -78,6 +78,14 @@ export function Hero() { DOWNLOAD FOR MAC + + + + DOWNLOAD FOR WINDOWS BETA + + DOWNLOAD THE SIGNED DMG + + + + DOWNLOAD FOR WINDOWS BETA + +
HOMEBREW
{BREW_COMMAND}
REQUIRES
-
macOS 13+, claude CLI signed in
+
macOS 13+ or Windows 10+, claude CLI signed in
LICENCE
Apache-2.0
REPO
@@ -44,7 +53,7 @@ export function Install() {
PLATFORM
-
macOS today · Windows, with your help
+
macOS · Windows (beta)
AUTHORS
{LEAFYCODE_NAME} and contributors
MADE IN
diff --git a/website/src/components/RatingPlate.tsx b/website/src/components/RatingPlate.tsx index 513a663..a349847 100644 --- a/website/src/components/RatingPlate.tsx +++ b/website/src/components/RatingPlate.tsx @@ -47,7 +47,7 @@ const CELLS: readonly Cell[] = [ label: "PLATFORM", value: "macOS", unit: "13+", - note: "WINDOWS: HELP WANTED", + note: "WINDOWS: BETA", }, { label: "MAKER", diff --git a/website/src/components/Synopsis.tsx b/website/src/components/Synopsis.tsx index 72edea1..fcfdc87 100644 --- a/website/src/components/Synopsis.tsx +++ b/website/src/components/Synopsis.tsx @@ -34,7 +34,7 @@ export function Synopsis() {
PLATFORM
- macOS 13+ · Windows: help wanted + macOS 13+ · Windows 10+ (beta)
NEEDS
claude CLI, signed in
diff --git a/website/src/lib/site.ts b/website/src/lib/site.ts index 84743c4..1722613 100644 --- a/website/src/lib/site.ts +++ b/website/src/lib/site.ts @@ -37,6 +37,11 @@ export const LATEST_RELEASE_URL = `${RELEASES_URL}/latest`; its DMG as `Kriyon.dmg` (.github/workflows/release.yml), and GitHub's latest/download path serves that name from the newest stable release. */ export const LATEST_DMG_URL = `${RELEASES_URL}/latest/download/Kriyon.dmg`; +/* The newest Windows installer, beta or stable: publishing a Windows build + puts its setup.exe on the feed-beta release as `Kriyon-setup.exe` + (scripts/publish-release.sh), and stable lands there too. Once Windows + ships stable, point this at feed-stable instead and drop the BETA tag. */ +export const WINDOWS_SETUP_URL = `${RELEASES_URL}/download/feed-beta/Kriyon-setup.exe`; export const CHANGELOG_URL = `${GITHUB_URL}/blob/main/CHANGELOG.md`; export const ISSUES_URL = `${GITHUB_URL}/issues`; export const DISCUSSIONS_URL = `${GITHUB_URL}/discussions`; diff --git a/website/src/routes/__root.tsx b/website/src/routes/__root.tsx index abe718e..a0dfdce 100644 --- a/website/src/routes/__root.tsx +++ b/website/src/routes/__root.tsx @@ -65,7 +65,7 @@ const STRUCTURED_DATA = { url: `${SITE_URL}/`, description: DESCRIPTION, applicationCategory: "DeveloperApplication", - operatingSystem: "macOS 13 or later", + operatingSystem: "macOS 13 or later, Windows 10 or later", softwareVersion: APP_VERSION.replace("v", ""), license: "https://www.apache.org/licenses/LICENSE-2.0", downloadUrl: LATEST_DMG_URL, diff --git a/website/src/styles.css b/website/src/styles.css index a29ea52..198ba9b 100644 --- a/website/src/styles.css +++ b/website/src/styles.css @@ -821,6 +821,14 @@ fieldset { background: var(--amber-tint); } +/* Marks a download that is still a beta, inside the button's label. */ +.tag-beta { + padding: 0 6px; + margin-left: 8px; + color: var(--amber-ink); + background: var(--amber-tint); +} + .hero-aside { display: grid; flex: 0 1 360px;