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;