From 94f325a242b7351f471d5c9b168b55f3fd9eaee8 Mon Sep 17 00:00:00 2001 From: Pubudu Kodikara Date: Mon, 2 Nov 2026 08:15:09 +0530 Subject: [PATCH 01/10] feat(release): build the Windows installer in the Release workflow A new build-windows job runs on a Windows runner after the Mac build has made the draft release. It builds the NSIS setup.exe with the update signature, uploads it into the same draft, and lets tauri-action merge the windows-x86_64 entry into latest.json. The setup.exe is also uploaded as Kriyon-setup.exe so the website can link to a fixed name. The update feed check now requires the Windows entry as well as both Mac entries, and it runs at the end of the Windows job, once the feed is complete. Publishing waits for both builds. Windows builds are not code-signed yet, so SmartScreen warns on install. The signing service is still to be chosen. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 121 ++++++++++++++++++++++++++++--- docs/RELEASING.md | 10 ++- docs/adr/0055-windows-support.md | 6 +- scripts/publish-lib.mjs | 16 +++- scripts/publish.mjs | 6 +- scripts/publish.test.mjs | 16 +++- 6 files changed, 151 insertions(+), 24 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23491d6..949fa6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ 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 -# docs/RELEASING.md. +# and a Windows x64 installer as one draft GitHub release, with its update +# feed checked. See ADR 0046, ADR 0055 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. @@ -135,6 +135,8 @@ jobs: attestations: write # build provenance outputs: tag: ${{ steps.version.outputs.tag }} + version: ${{ steps.version.outputs.version }} + release-id: ${{ steps.tauri.outputs.releaseId }} env: CHANNEL: ${{ needs.prepare.outputs.channel }} BUNDLE: target/universal-apple-darwin/release/bundle @@ -295,21 +297,120 @@ 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 }} - run: | - gh release download "$TAG" --pattern latest.json --dir "$RUNNER_TEMP" - node scripts/publish.mjs check-feed "$RUNNER_TEMP/latest.json" --version "$VERSION" + # The feed is checked in build-windows, once it has the Windows entry. - name: Remove the App Store Connect key if: ${{ always() }} run: rm -f "$RUNNER_TEMP/AuthKey.p8" + # Runs after the Mac build, which made the draft: tauri-action uploads into + # it and merges the windows-x86_64 entry into its latest.json. Unsigned + # until Windows code signing is chosen (ADR 0055), so SmartScreen warns. + build-windows: + name: Build for Windows (${{ needs.prepare.outputs.channel }}) + needs: [prepare, build] + 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.build.outputs.tag }} + VERSION: ${{ needs.build.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 + + # The same commands as the Mac job, on the same tags and start time, so + # they give the same version; a mismatch stops before anything uploads. + - name: Set the 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 + if [[ "$version" != "$VERSION" ]]; then + echo "::error::Windows would build $version, but the Mac build is $VERSION." + exit 1 + fi + + - 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.build.outputs.release-id }} + updaterJsonPreferNsis: true + + - 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 + setups=("$BUNDLE"/nsis/*-setup.exe) + if (( ${#setups[@]} != 1 )) || [[ ! -f "${setups[0]}" ]]; then + echo "::error::Expected one setup.exe, found: ${setups[*]}" + exit 1 + fi + echo "path=${setups[0]}" >> "$GITHUB_OUTPUT" + + # Kriyon-setup.exe is the same file under a name that never changes, so + # releases/latest/download/Kriyon-setup.exe always fetches the newest + # stable installer, as Kriyon.dmg does for the Mac. + - name: Add the fixed-name installer + env: + SETUP: ${{ steps.setup.outputs.path }} + run: | + cp "$SETUP" "$RUNNER_TEMP/Kriyon-setup.exe" + gh release upload "$TAG" "$RUNNER_TEMP/Kriyon-setup.exe" --clobber + + - 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 }} + + - name: Check the update feed + run: | + gh release download "$TAG" --pattern latest.json --dir "$RUNNER_TEMP" --clobber + node scripts/publish.mjs check-feed "$RUNNER_TEMP/latest.json" --version "$VERSION" + publish: name: Publish (${{ needs.prepare.outputs.channel }}) - needs: [prepare, build] + needs: [prepare, build, build-windows] # Stable waits for a person: they check the draft, then run publish.yml. if: ${{ needs.prepare.outputs.channel != 'stable' }} runs-on: ubuntu-24.04 diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 29b6da4..b621d30 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,6 +1,6 @@ # Releasing Kriyon -How a build gets from `dev` to people's Macs. Why it works this way is in ADR 0045 (versions +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) and ADR 0046 (builds, publishing and feeds). | Channel | Built from | When | Who publishes | @@ -14,6 +14,14 @@ notarized, and checked by `scripts/check-mac-bundle.sh`. It lands as a draft rel 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. +Once the Mac build has made the draft, a second job builds the Windows x64 installer on a +Windows runner and adds it to the same draft: `Kriyon_0.13.0_x64-setup.exe`, its `.sig`, and the +`windows-x86_64` entry in `latest.json`. The feed check needs both Mac entries and the Windows +one. The setup.exe is uploaded again as `Kriyon-setup.exe`, so +`https://github.com/LeafyCode/kriyon/releases/latest/download/Kriyon-setup.exe` always serves the +newest stable installer. Windows builds are not code-signed yet (ADR 0055), so SmartScreen shows +"Windows protected your PC" until someone clicks More info, then Run anyway. + 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 diff --git a/docs/adr/0055-windows-support.md b/docs/adr/0055-windows-support.md index a38be8d..cc13abd 100644 --- a/docs/adr/0055-windows-support.md +++ b/docs/adr/0055-windows-support.md @@ -79,5 +79,7 @@ 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 and update feed (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/scripts/publish-lib.mjs b/scripts/publish-lib.mjs index bc64bb4..c3addeb 100644 --- a/scripts/publish-lib.mjs +++ b/scripts/publish-lib.mjs @@ -10,8 +10,16 @@ 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"]; +/** + * The feed entries every release must have: one per Mac chip (the universal + * build) and the Windows x64 installer. The updater looks up the user's own + * platform, so a missing entry leaves those users on an old version. + */ +const REQUIRED_PLATFORMS = [ + "darwin-aarch64", + "darwin-x86_64", + "windows-x86_64", +]; const LEADING_V = /^v/; const SHA256 = /^[0-9a-f]{64}$/; @@ -149,7 +157,7 @@ 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. + * incomplete, so every entry is checked, not only the required ones. */ export const feedProblems = (feed, version) => { const problems = []; @@ -164,7 +172,7 @@ 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_PLATFORMS) { if (!(name in platforms)) { problems.push(`${name} is missing`); } diff --git a/scripts/publish.mjs b/scripts/publish.mjs index 5068586..09b396f 100755 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -3,8 +3,8 @@ // 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 +// Check an update feed before anyone can get it: both Mac entries and +// the Windows entry 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-key @@ -93,7 +93,7 @@ const checkFeed = async (feedFile, version) => { if (problems.length > 0) { fail(`The feed isn't ready:\n - ${problems.join("\n - ")}`); } - ok(`The feed announces ${version} with both Mac entries`); + ok(`The feed announces ${version} for Mac and Windows`); const publicKey = updaterKey(); // A universal build lists one file under several names; fetch each once. const urls = [ diff --git a/scripts/publish.test.mjs b/scripts/publish.test.mjs index c482b9d..c28cdd1 100644 --- a/scripts/publish.test.mjs +++ b/scripts/publish.test.mjs @@ -249,11 +249,19 @@ describe("the feed file", () => { version: "0.13.0", notes: "", pub_date: "2026-10-16T00:00:00Z", - platforms: { "darwin-aarch64": entry, "darwin-x86_64": entry }, + platforms: { + "darwin-aarch64": entry, + "darwin-x86_64": entry, + "windows-x86_64": entry, + }, }; - test("each Mac chip's entry is required", () => { - for (const missing of ["darwin-aarch64", "darwin-x86_64"]) { + test("each Mac chip's entry and the Windows entry are required", () => { + for (const missing of [ + "darwin-aarch64", + "darwin-x86_64", + "windows-x86_64", + ]) { const { [missing]: _, ...rest } = feed.platforms; assert.deepEqual(feedProblems({ ...feed, platforms: rest }, "0.13.0"), [ `${missing} is missing`, @@ -261,7 +269,7 @@ describe("the feed file", () => { } }); - test("a feed with both Mac entries for the expected version has no problems", () => { + test("a feed with every entry for the expected version has no problems", () => { assert.deepEqual(feedProblems(feed, "0.13.0"), []); }); From 71fecc554afcb91425f75f92df5f14a9d0e458ad Mon Sep 17 00:00:00 2001 From: Pubudu Kodikara Date: Mon, 2 Nov 2026 10:20:26 +0530 Subject: [PATCH 02/10] feat(update): read one update feed file per platform Each channel's feed is now a file per platform on a fixed release: feed-/darwin.json or windows.json, chosen by the updater's {{target}} substitution. A release built for one platform can then move only that platform's feed, so an urgent Mac-only or Windows-only fix never waits for the other build. A feed that is newer but has no entry for this platform now reads as no update. The updater would otherwise fail the check with "target not found" and show the person an error. Co-Authored-By: Claude Opus 5.5 --- crates/kriyon-core/src/update.rs | 46 +++++++++++++++++++++++++----- docs/context/runtime.md | 12 ++++---- src-tauri/src/update_rt.rs | 48 ++++++++++++++++++++++++++++++-- 3 files changed, 92 insertions(+), 14 deletions(-) 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/context/runtime.md b/docs/context/runtime.md index 2cc5fd2..de39382 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 before 0.16 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/src-tauri/src/update_rt.rs b/src-tauri/src/update_rt.rs index 732cc11..b8c8554 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,33 @@ 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(&[]))); + } + /// An app with no updater config, as debug builds have: no check ever /// reaches the network. fn mock_app() -> tauri::App { From 0cdb372749495feb7677d75e24ab763bcee42491 Mon Sep 17 00:00:00 2001 From: Pubudu Kodikara Date: Mon, 2 Nov 2026 10:31:07 +0530 Subject: [PATCH 03/10] feat(release): ship each platform's build and update feed on its own The Mac and Windows builds now run side by side into one draft made by a new draft job. Each checks its own feed file (darwin.json, windows.json) and uploads it last, so its presence means that platform passed every check. A build that fails removes its files from the draft (scripts/drop-platform.sh), and betas and nightlies publish for whichever platforms finished. A run by hand can build one platform, and Publish can publish a stable draft for one platform only, so an urgent fix ships without the other. publish-release.sh moves each built platform's file on the channel's fixed feed release, now including feed-stable, only forward. Builds up to 0.15.0 keep updating: the Mac latest.json is still uploaded, stable stays GitHub's latest release only when it has a Mac build, and beta and nightly copies go on their feed releases. A stable Windows build puts its installer on feed-stable as Kriyon-setup.exe. The Homebrew tap moves only for a Mac build. ADR 0056 records the decision; docs/RELEASING.md explains the flows. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 35 ++- .github/workflows/release.yml | 277 ++++++++++++------ docs/RELEASING.md | 122 +++++--- docs/adr/0055-windows-support.md | 3 +- docs/adr/0056-one-update-feed-per-platform.md | 64 ++++ docs/context/runtime.md | 2 +- scripts/drop-platform.sh | 35 +++ scripts/publish-lib.mjs | 107 ++++++- scripts/publish-release.sh | 176 ++++++++--- scripts/publish.mjs | 81 ++++- scripts/publish.test.mjs | 145 ++++++--- 11 files changed, 812 insertions(+), 235 deletions(-) create mode 100644 docs/adr/0056-one-update-feed-per-platform.md create mode 100755 scripts/drop-platform.sh 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 949fa6e..1506ec1 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 -# and a Windows x64 installer as one draft GitHub release, with its update -# feed checked. See ADR 0046, ADR 0055 and docs/RELEASING.md. +# 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,6 +64,10 @@ 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: @@ -85,7 +102,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,30 +138,48 @@ jobs: echo "::notice::No new commits on dev since the last nightly." build=false fi + # The same commands each build job runs with --write, on the same + # tags and start time, so every job builds this 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 }} - version: ${{ steps.version.outputs.version }} - release-id: ${{ steps.tauri.outputs.releaseId }} + 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: @@ -151,37 +189,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" @@ -191,8 +210,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 @@ -202,6 +219,73 @@ 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 + 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 + if [[ "$version" != "$VERSION" ]]; then + echo "::error::The Mac build would be $version, but this release is $VERSION." + exit 1 + fi + - name: Write the App Store Connect key env: APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} @@ -209,7 +293,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: @@ -227,12 +311,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). @@ -253,7 +334,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: | @@ -269,7 +349,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 @@ -297,18 +376,34 @@ jobs: ${{ steps.dmg.outputs.path }} ${{ env.BUNDLE }}/macos/Kriyon.app.tar.gz - # The feed is checked in build-windows, once it has the Windows entry. + # 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" --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 Mac build takes its files back out, so the release can still + # go out for Windows without a half-made Mac download in it. + - name: Remove the Mac files from the draft + if: ${{ failure() }} + run: scripts/drop-platform.sh "$TAG" darwin - name: Remove the App Store Connect key if: ${{ always() }} run: rm -f "$RUNNER_TEMP/AuthKey.p8" - # Runs after the Mac build, which made the draft: tauri-action uploads into - # it and merges the windows-x86_64 entry into its latest.json. Unsigned - # until Windows code signing is chosen (ADR 0055), so SmartScreen warns. + # 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, build] + needs: [prepare, draft] + if: ${{ needs.prepare.outputs.windows == 'true' }} runs-on: windows-2025 timeout-minutes: 90 environment: ${{ needs.prepare.outputs.channel == 'stable' && 'release' || 'prerelease' }} @@ -324,8 +419,8 @@ jobs: BUNDLE: target/release/bundle GH_TOKEN: ${{ github.token }} PUBLIC_REPO: ${{ needs.prepare.outputs.public }} - TAG: ${{ needs.build.outputs.tag }} - VERSION: ${{ needs.build.outputs.version }} + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -340,8 +435,6 @@ jobs: rustup default stable - run: pnpm install --frozen-lockfile - # The same commands as the Mac job, on the same tags and start time, so - # they give the same version; a mismatch stops before anything uploads. - name: Set the version env: STARTED: ${{ needs.prepare.outputs.started }} @@ -352,7 +445,7 @@ jobs: nightly) version="$(node scripts/release.mjs nightly --date "$STARTED" --write)" ;; esac if [[ "$version" != "$VERSION" ]]; then - echo "::error::Windows would build $version, but the Mac build is $VERSION." + echo "::error::The Windows build would be $version, but this release is $VERSION." exit 1 fi @@ -368,8 +461,10 @@ jobs: # 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.build.outputs.release-id }} - updaterJsonPreferNsis: true + 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 @@ -381,42 +476,60 @@ jobs: exit 1 fi setups=("$BUNDLE"/nsis/*-setup.exe) - if (( ${#setups[@]} != 1 )) || [[ ! -f "${setups[0]}" ]]; then - echo "::error::Expected one setup.exe, found: ${setups[*]}" + if (( ${#setups[@]} != 1 )) || [[ ! -f "${setups[0]}" || ! -f "${setups[0]}.sig" ]]; then + echo "::error::Expected one setup.exe with its .sig, found: ${setups[*]}" exit 1 fi echo "path=${setups[0]}" >> "$GITHUB_OUTPUT" - # Kriyon-setup.exe is the same file under a name that never changes, so - # releases/latest/download/Kriyon-setup.exe always fetches the newest - # stable installer, as Kriyon.dmg does for the Mac. - - name: Add the fixed-name installer - env: - SETUP: ${{ steps.setup.outputs.path }} - run: | - cp "$SETUP" "$RUNNER_TEMP/Kriyon-setup.exe" - gh release upload "$TAG" "$RUNNER_TEMP/Kriyon-setup.exe" --clobber - - 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 }} - - name: Check the update feed + # 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: | - gh release download "$TAG" --pattern latest.json --dir "$RUNNER_TEMP" --clobber - node scripts/publish.mjs check-feed "$RUNNER_TEMP/latest.json" --version "$VERSION" + url="$(gh release view "$TAG" --json assets \ + --jq '.assets[] | select(.name | endswith("-setup.exe")) | .apiUrl')" + if [[ -z "$url" || "$url" == *$'\n'* ]]; then + echo "::error::Expected one setup.exe on $TAG, found: ${url:-none}" + exit 1 + fi + 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 Windows build takes its files back out, so the release can + # still go out for the Mac without a half-made installer in it. + - name: Remove the Windows files from the draft + if: ${{ failure() }} + run: scripts/drop-platform.sh "$TAG" windows publish: name: Publish (${{ needs.prepare.outputs.channel }}) - needs: [prepare, build, build-windows] + 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: @@ -427,5 +540,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/docs/RELEASING.md b/docs/RELEASING.md index b621d30..1d78b94 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 and Windows PCs. 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,61 +10,102 @@ 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: -Once the Mac build has made the draft, a second job builds the Windows x64 installer on a -Windows runner and adds it to the same draft: `Kriyon_0.13.0_x64-setup.exe`, its `.sig`, and the -`windows-x86_64` entry in `latest.json`. The feed check needs both Mac entries and the Windows -one. The setup.exe is uploaded again as `Kriyon-setup.exe`, so -`https://github.com/LeafyCode/kriyon/releases/latest/download/Kriyon-setup.exe` always serves the -newest stable installer. Windows builds are not code-signed yet (ADR 0055), so SmartScreen shows -"Windows protected your PC" until someone clicks More info, then Run anyway. +- **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. The newest stable Windows installer is always +`https://github.com/LeafyCode/kriyon/releases/download/feed-stable/Kriyon-setup.exe`, which +publishing puts there; link the website's Windows button to that, not to +`releases/latest/download/…`, which a Mac-only release would leave 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`) + +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. + +### 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 installer on + `feed-stable/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.** Its files are already out of the draft. 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 @@ -95,7 +137,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: @@ -103,11 +145,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/0055-windows-support.md b/docs/adr/0055-windows-support.md index cc13abd..9cdf1cc 100644 --- a/docs/adr/0055-windows-support.md +++ b/docs/adr/0055-windows-support.md @@ -80,6 +80,7 @@ runs natively on Windows and runs hook commands through Git Bash. scripts to a small Rust program would remove that dependency; it is the largest remaining Windows gap. - The Release workflow builds the Windows NSIS installer beside the Mac build, - into the same draft release and update feed (docs/RELEASING.md). It is not + 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..021a8e6 --- /dev/null +++ b/docs/adr/0056-one-update-feed-per-platform.md @@ -0,0 +1,64 @@ +# 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. + +Other desktop apps keep platforms apart: + +- electron-builder writes `latest.yml` (Windows), `latest-mac.yml` and `latest-linux.yml`, one + per platform. +- VS Code asks its update service per platform (`/api/update///`), + and Zed passes the OS and arch to its server. +- Tauri apps Yaak (`update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}`) and + GitButler (`…/releases/nightly/{{target}}-{{arch}}/{{current_version}}`) use the updater's + endpoint templating, which the Tauri 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. +- **The Windows download link** is `feed-stable/Kriyon-setup.exe`, put there when a stable + Windows build is published, since `releases/latest` may be a Mac-only release. + +## 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. +- 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 de39382..13a1509 100644 --- a/docs/context/runtime.md +++ b/docs/context/runtime.md @@ -175,7 +175,7 @@ Part of the Kriyon glossary. The index and the naming rule are in [`CONTEXT.md`] 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 before 0.16 read the + `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 diff --git a/scripts/drop-platform.sh b/scripts/drop-platform.sh new file mode 100755 index 0000000..fb59de6 --- /dev/null +++ b/scripts/drop-platform.sh @@ -0,0 +1,35 @@ +#!/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" + +# No release yet (the build failed before uploading): nothing to remove. +if ! draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then + echo " ✓ $tag has no release yet; nothing to remove" >&2 + exit 0 +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 c3addeb..075bb3f 100644 --- a/scripts/publish-lib.mjs +++ b/scripts/publish-lib.mjs @@ -11,15 +11,88 @@ import { createHash, createPublicKey, verify } from "node:crypto"; import { channelOf, compareVersions, isNightly } from "./release-lib.mjs"; /** - * The feed entries every release must have: one per Mac chip (the universal - * build) and the Windows x64 installer. The updater looks up the user's own - * platform, so a missing entry leaves those users on an old version. + * The entries each platform's feed file must have (ADR 0056): one per Mac + * chip for the universal build, and the Windows x64 installer. The keys are + * the updater's `{{target}}` values, which also name the files (darwin.json, + * windows.json). A missing entry leaves those users on an old version. */ -const REQUIRED_PLATFORMS = [ - "darwin-aarch64", - "darwin-x86_64", - "windows-x86_64", -]; +const REQUIRED_ENTRIES = { + darwin: ["darwin-aarch64", "darwin-x86_64"], + windows: ["windows-x86_64"], +}; + +/** 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 = REQUIRED_ENTRIES[platform]; + 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(REQUIRED_ENTRIES, platform)) { + return platform; + } + throw new Error(`${file} is not a platform's feed file`); +}; + +/** The release files each platform's build uploads, by name. */ +const PLATFORM_ASSETS = { + darwin: + /^(darwin\.json|latest\.json|Kriyon(_.*)?\.dmg|.*\.app\.tar\.gz(\.sig)?)$/, + windows: /^(windows\.json|.*-setup\.exe(\.sig)?)$/, +}; + +/** + * The platform whose build uploaded the release file `name`, or undefined. + * A failed build removes its own files from the draft by this rule, and a + * release published for one platform only drops the other's. + */ +export const assetPlatform = (name) => + Object.keys(PLATFORM_ASSETS).find((platform) => + PLATFORM_ASSETS[platform].test(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}$/; @@ -155,11 +228,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 required 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" || @@ -172,11 +248,16 @@ export const feedProblems = (feed, version) => { problems.push("The feed has no platforms"); return problems; } - for (const name of REQUIRED_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..a6a8f41 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -1,31 +1,54 @@ #!/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: the other's files are taken out first +# (scripts/drop-platform.sh), so its users are not offered this version. +# 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. +# 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 stable Windows +# build also puts its installer on feed-stable as Kriyon-setup.exe, the +# fixed name the website links to. +# 5. Keep only the newest nightlies. # -# 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:-}" +case "$only" in + "" | darwin | windows) ;; + *) + echo "✗ \"$only\" is not darwin or windows" >&2 + exit 2 + ;; +esac version="${tag#v}" channel="$(node scripts/publish.mjs channel "$tag")" work="$(mktemp -d)" @@ -33,63 +56,124 @@ 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)" +if [[ -n "$only" && "$draft" == true ]]; then + for other in darwin windows; do + if [[ "$other" != "$only" ]]; then + scripts/drop-platform.sh "$tag" "$other" + fi + done +fi +assets="$(gh release view "$tag" --json assets --jq '.assets[].name')" + +platforms=() +for platform in darwin windows; 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 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 +# Put $work/ on the feed- pre-release, unless the copy there names +# a newer version. Prints true when it moved. move_feed() { - local feed="feed-$1" + local feed="feed-$1" file="$2" if ! gh release view "$feed" >/dev/null 2>&1; then gh release create "$feed" --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")" + # 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 + echo 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 + echo 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 + moved="$(move_feed "$feed" "$platform.json")" + if [[ "$feed" == stable && "$platform" == windows && "$moved" == true ]]; then + gh release download "$tag" --pattern 'Kriyon_*_x64-setup.exe' --dir "$work/setup" --clobber + setups=("$work"/setup/*.exe) + if (( ${#setups[@]} != 1 )); then + echo "✗ Expected one setup.exe on $tag, found: ${setups[*]}" >&2 + exit 1 + fi + cp "${setups[0]}" "$work/Kriyon-setup.exe" + gh release upload feed-stable "$work/Kriyon-setup.exe" --clobber + echo " ✓ feed-stable/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 >/dev/null + fi +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 diff --git a/scripts/publish.mjs b/scripts/publish.mjs index 09b396f..5a2a838 100755 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -2,11 +2,20 @@ // 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 and -// the Windows entry 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 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 +42,17 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { + assetPlatform, + feedPlatform, feedProblems, homebrewCask, keyProblem, nightliesNewestFirst, + platformFeed, publicRepoFrom, staleNightlies, verifyUpdateSignature, + windowsFeed, } from "./publish-lib.mjs"; import { channelOf, isNewest } from "./release-lib.mjs"; @@ -86,14 +99,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} for Mac and Windows`); + 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 +131,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 +184,15 @@ const main = async () => { case "check-feed": await checkFeed(args[0], values.version); 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 c28cdd1..e4799a0 100644 --- a/scripts/publish.test.mjs +++ b/scripts/publish.test.mjs @@ -7,16 +7,22 @@ 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, + platformFeed, publicRepoFrom, staleNightlies, verifyUpdateSignature, + windowsFeed, } from "./publish-lib.mjs"; 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,65 +251,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, - "windows-x86_64": entry, - }, + 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 and the Windows entry are required", () => { - for (const missing of [ - "darwin-aarch64", - "darwin-x86_64", - "windows-x86_64", - ]) { - const { [missing]: _, ...rest } = feed.platforms; - assert.deepEqual(feedProblems({ ...feed, platforms: rest }, "0.13.0"), [ - `${missing} is missing`, - ]); + test("a Mac feed needs both chips' entries", () => { + for (const missing of ["darwin-aarch64", "darwin-x86_64"]) { + const { [missing]: _, ...rest } = mac.platforms; + assert.deepEqual( + feedProblems({ ...mac, platforms: rest }, "0.13.0", "darwin"), + [`${missing} is missing`] + ); } }); - test("a feed with every entry 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"] ); @@ -313,23 +331,76 @@ 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("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("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", () => { From 3eda17bd6173297ec633c8b63f4e84994ee025cf Mon Sep 17 00:00:00 2001 From: Pubudu Kodikara Date: Mon, 2 Nov 2026 15:18:24 +0530 Subject: [PATCH 04/10] fix(release): never leave a platform's update feed file missing Each publish run now fills in any channel's missing darwin.json or windows.json. A missing file is 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 Mac's file is made from that channel's latest.json, so the first run puts 0.15.0 on feed-stable; the Windows file gets the same version with no entries, which the app reads as no update until a Windows build replaces it. From the fourth stable release after 0.15.0, Publish also warns in its run summary that latest.json can probably be dropped. Co-Authored-By: Claude Opus 5.5 --- docs/RELEASING.md | 9 ++- docs/adr/0056-one-update-feed-per-platform.md | 7 +- scripts/publish-release.sh | 77 +++++++++++++++++-- scripts/publish.test.mjs | 4 + src-tauri/src/update_rt.rs | 11 +++ 5 files changed, 99 insertions(+), 9 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1d78b94..3441256 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -45,12 +45,19 @@ as `darwin.json` or `windows.json`: 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. +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 diff --git a/docs/adr/0056-one-update-feed-per-platform.md b/docs/adr/0056-one-update-feed-per-platform.md index 021a8e6..05f01cb 100644 --- a/docs/adr/0056-one-update-feed-per-platform.md +++ b/docs/adr/0056-one-update-feed-per-platform.md @@ -50,6 +50,10 @@ Other desktop apps keep platforms apart: 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 `feed-stable/Kriyon-setup.exe`, put there when a stable Windows build is published, since `releases/latest` may be a Mac-only release. @@ -59,6 +63,7 @@ Other desktop apps keep platforms apart: - 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. +- `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/scripts/publish-release.sh b/scripts/publish-release.sh index a6a8f41..cb5f4fc 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -25,7 +25,16 @@ # put on the beta feeds too, so beta users land on it. A stable Windows # build also puts its installer on feed-stable as Kriyon-setup.exe, the # fixed name the website links to. -# 5. Keep only the newest nightlies. +# 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 3, so a run that # failed after publishing can still finish moving the feeds. A feed only moves @@ -111,16 +120,21 @@ else echo " ✓ Published $tag" >&2 fi -# Put $work/ on the feed- pre-release, unless the copy there names -# a newer version. Prints true when it moved. -move_feed() { - local feed="feed-$1" file="$2" - 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 the $1 channel's update feeds, one file per platform. Downloads are on each version's own release." >&2 fi +} + +# Put $work/ on the feed- pre-release, unless the copy there names +# a newer version. Prints true when it moved. +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')" @@ -169,6 +183,39 @@ for feed in "${feeds[@]}"; do 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 darwin windows; 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 \ @@ -177,3 +224,19 @@ if [[ "$channel" == nightly ]]; then 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.test.mjs b/scripts/publish.test.mjs index e4799a0..426cc29 100644 --- a/scripts/publish.test.mjs +++ b/scripts/publish.test.mjs @@ -389,6 +389,10 @@ describe("the feed file", () => { 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({ diff --git a/src-tauri/src/update_rt.rs b/src-tauri/src/update_rt.rs index b8c8554..1baf310 100644 --- a/src-tauri/src/update_rt.rs +++ b/src-tauri/src/update_rt.rs @@ -554,6 +554,17 @@ mod tests { 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 { From 79d26cfdff2008a6c04f8d06d44611bfc8840530 Mon Sep 17 00:00:00 2001 From: Pubudu Kodikara Date: Mon, 2 Nov 2026 16:00:24 +0530 Subject: [PATCH 05/10] feat(website): add a Windows download button marked beta The hero and install sections get a "Download for Windows" button with a BETA tag. It links to feed-beta/Kriyon-setup.exe: publishing a Windows build now puts its installer on each feed release it moves, not only on feed-stable, so the beta installer has a fixed link too. Stable also lands on feed-beta, so the link is the newest of beta and stable. The platform facts, rating plate, contribute card and structured data now say Windows is in beta instead of "help wanted". Co-Authored-By: Claude Opus 5.5 --- docs/RELEASING.md | 16 ++++++++++------ docs/adr/0056-one-update-feed-per-platform.md | 6 ++++-- scripts/publish-release.sh | 13 +++++++------ website/src/components/Contribute.tsx | 6 +++--- website/src/components/Hero.test.tsx | 10 ++++++++++ website/src/components/Hero.tsx | 10 +++++++++- website/src/components/Install.tsx | 13 +++++++++++-- website/src/components/RatingPlate.tsx | 2 +- website/src/components/Synopsis.tsx | 2 +- website/src/lib/site.ts | 5 +++++ website/src/routes/__root.tsx | 2 +- website/src/styles.css | 8 ++++++++ 12 files changed, 70 insertions(+), 23 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3441256..06e952e 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -30,10 +30,14 @@ The DMG is uploaded twice: once with its version in the name (`Kriyon_0.13.0_uni 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 Mac release: only a stable release with a Mac build becomes GitHub's latest -release. The newest stable Windows installer is always -`https://github.com/LeafyCode/kriyon/releases/download/feed-stable/Kriyon-setup.exe`, which -publishing puts there; link the website's Windows button to that, not to -`releases/latest/download/…`, which a Mac-only release would leave without one. +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 @@ -80,8 +84,8 @@ nobody looks for the other download. 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 each feed file, - publishes (GitHub makes the tag), moves the stable and beta feeds, puts the installer on - `feed-stable/Kriyon-setup.exe` and, for a Mac build, bumps the Homebrew tap. + 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 diff --git a/docs/adr/0056-one-update-feed-per-platform.md b/docs/adr/0056-one-update-feed-per-platform.md index 05f01cb..e0a1d2f 100644 --- a/docs/adr/0056-one-update-feed-per-platform.md +++ b/docs/adr/0056-one-update-feed-per-platform.md @@ -54,8 +54,10 @@ Other desktop apps keep platforms apart: 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 `feed-stable/Kriyon-setup.exe`, put there when a stable - Windows build is published, since `releases/latest` may be a Mac-only release. +- **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 diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index cb5f4fc..1e5c2a5 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -22,9 +22,10 @@ # 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 stable Windows -# build also puts its installer on feed-stable as Kriyon-setup.exe, the -# fixed name the website links to. +# 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 @@ -164,7 +165,7 @@ esac for feed in "${feeds[@]}"; do for platform in "${platforms[@]}"; do moved="$(move_feed "$feed" "$platform.json")" - if [[ "$feed" == stable && "$platform" == windows && "$moved" == true ]]; then + if [[ "$platform" == windows && "$moved" == true ]]; then gh release download "$tag" --pattern 'Kriyon_*_x64-setup.exe' --dir "$work/setup" --clobber setups=("$work"/setup/*.exe) if (( ${#setups[@]} != 1 )); then @@ -172,8 +173,8 @@ for feed in "${feeds[@]}"; do exit 1 fi cp "${setups[0]}" "$work/Kriyon-setup.exe" - gh release upload feed-stable "$work/Kriyon-setup.exe" --clobber - echo " ✓ feed-stable/Kriyon-setup.exe is $version's installer" >&2 + 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, 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..0032df0 100644 --- a/website/src/components/Hero.test.tsx +++ b/website/src/components/Hero.test.tsx @@ -6,6 +6,7 @@ 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. @@ -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; From 70de1d3efc5e50d4ed573a589992337a4865ec20 Mon Sep 17 00:00:00 2001 From: Pubudu Kodikara Date: Mon, 2 Nov 2026 16:18:22 +0530 Subject: [PATCH 06/10] fix(release): publish safely when a platform is missing or gh fails - publish-release.sh checks the chosen platforms' feed files before it drops anything, so a wrong choice or a failed check no longer throws away the other platform's good build. - It then drops every platform without a feed file, not only the one not chosen, so files from a build that timed out before it could clean up never ship. - move_feed no longer runs inside $(...), where set -e is off: a failed upload or lookup now stops the run instead of reporting success or moving a feed backwards. - drop-platform.sh treats only "release not found" as nothing to remove; any other gh error fails the step. - The platform list and the Windows installer's name rule now live in publish-lib.mjs (publish.mjs platforms, windows-installer), and the scripts ask for them instead of repeating them. Co-Authored-By: Claude Opus 5.5 --- docs/RELEASING.md | 3 +- scripts/drop-platform.sh | 16 ++++++--- scripts/publish-lib.mjs | 41 ++++++++++++++++++++--- scripts/publish-release.sh | 66 ++++++++++++++++++++------------------ scripts/publish.mjs | 18 +++++++++++ scripts/publish.test.mjs | 32 ++++++++++++++++++ 6 files changed, 136 insertions(+), 40 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 06e952e..b51dfdc 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -107,7 +107,8 @@ nobody looks for the other download. ## When something fails -- **One platform's build failed.** Its files are already out of the draft. A beta or nightly +- **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: diff --git a/scripts/drop-platform.sh b/scripts/drop-platform.sh index fb59de6..221d594 100755 --- a/scripts/drop-platform.sh +++ b/scripts/drop-platform.sh @@ -16,11 +16,19 @@ if (( $# != 2 )); then fi tag="$1" platform="$2" +errors="$(mktemp)" +trap 'rm -f "$errors"' EXIT -# No release yet (the build failed before uploading): nothing to remove. -if ! draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - echo " ✓ $tag has no release yet; nothing to remove" >&2 - exit 0 +# 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 diff --git a/scripts/publish-lib.mjs b/scripts/publish-lib.mjs index 075bb3f..0460ff7 100644 --- a/scripts/publish-lib.mjs +++ b/scripts/publish-lib.mjs @@ -21,6 +21,12 @@ const REQUIRED_ENTRIES = { windows: ["windows-x86_64"], }; +/** + * The platforms Kriyon releases for, by `{{target}}` name. The release + * scripts ask `publish.mjs platforms` for this list instead of writing it out. + */ +export const PLATFORMS = Object.keys(REQUIRED_ENTRIES); + /** The feed file builds up to 0.15.0 read; it carries the Mac entries only. */ const LEGACY_FEED = "latest.json"; @@ -37,6 +43,7 @@ const requiredEntries = (platform) => { const PATH_SEPARATOR = /[\\/]/; const FEED_FILE = /^(.+)\.json$/; +const SIG_SUFFIX = /\.sig$/; /** The platform a feed file is for, from its name (`…/windows.json`). */ export const feedPlatform = (file) => { @@ -51,11 +58,37 @@ export const feedPlatform = (file) => { throw new Error(`${file} is not a platform's feed file`); }; +/** The Windows installer, the NSIS setup.exe, by file name. */ +const WINDOWS_INSTALLER = /-setup\.exe$/; + +/** + * 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]; +}; + +const MAC_ASSET = + /^(darwin\.json|latest\.json|Kriyon(_.*)?\.dmg|.*\.app\.tar\.gz(\.sig)?)$/; + /** The release files each platform's build uploads, by name. */ const PLATFORM_ASSETS = { - darwin: - /^(darwin\.json|latest\.json|Kriyon(_.*)?\.dmg|.*\.app\.tar\.gz(\.sig)?)$/, - windows: /^(windows\.json|.*-setup\.exe(\.sig)?)$/, + darwin: (name) => MAC_ASSET.test(name), + windows: (name) => + name === "windows.json" || + WINDOWS_INSTALLER.test(name.replace(SIG_SUFFIX, "")), }; /** @@ -65,7 +98,7 @@ const PLATFORM_ASSETS = { */ export const assetPlatform = (name) => Object.keys(PLATFORM_ASSETS).find((platform) => - PLATFORM_ASSETS[platform].test(name) + PLATFORM_ASSETS[platform](name) ); /** `feed` with only `platform`'s entries, for that platform's own file. */ diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index 1e5c2a5..6d1c0e3 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -8,11 +8,14 @@ # 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: the other's files are taken out first -# (scripts/drop-platform.sh), so its users are not offered this version. +# 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. +# 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 @@ -52,13 +55,13 @@ if (( $# < 1 || $# > 2 )); then fi tag="$1" only="${2:-}" -case "$only" in - "" | darwin | windows) ;; - *) - echo "✗ \"$only\" is not darwin or windows" >&2 - exit 2 - ;; -esac +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)" @@ -66,17 +69,10 @@ 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)" -if [[ -n "$only" && "$draft" == true ]]; then - for other in darwin windows; do - if [[ "$other" != "$only" ]]; then - scripts/drop-platform.sh "$tag" "$other" - fi - done -fi assets="$(gh release view "$tag" --json assets --jq '.assets[].name')" platforms=() -for platform in darwin windows; do +for platform in "${all_platforms[@]}"; do if [[ -n "$only" && "$platform" != "$only" ]]; then continue fi @@ -100,6 +96,16 @@ if has darwin; then 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 @@ -132,7 +138,8 @@ ensure_feed_release() { } # Put $work/ on the feed- pre-release, unless the copy there names -# a newer version. Prints true when it moved. +# 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" @@ -146,13 +153,13 @@ move_feed() { forward="$(node scripts/publish.mjs moves-forward "$version" "$current")" if [[ "$forward" != true ]]; then echo " ✓ $feed/$file already names a newer version; left as it is" >&2 - echo false + moved=false return fi fi gh release upload "$feed" "$work/$file" --clobber >&2 echo " ✓ $feed/$file names $version" >&2 - echo true + moved=true } # The feed releases this channel's builds go on: stable lands on beta too. @@ -164,15 +171,12 @@ esac for feed in "${feeds[@]}"; do for platform in "${platforms[@]}"; do - moved="$(move_feed "$feed" "$platform.json")" + move_feed "$feed" "$platform.json" if [[ "$platform" == windows && "$moved" == true ]]; then - gh release download "$tag" --pattern 'Kriyon_*_x64-setup.exe' --dir "$work/setup" --clobber - setups=("$work"/setup/*.exe) - if (( ${#setups[@]} != 1 )); then - echo "✗ Expected one setup.exe on $tag, found: ${setups[*]}" >&2 - exit 1 - fi - cp "${setups[0]}" "$work/Kriyon-setup.exe" + # 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 @@ -180,7 +184,7 @@ for feed in "${feeds[@]}"; do # 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 >/dev/null + move_feed "$feed" latest.json fi done @@ -190,7 +194,7 @@ 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 darwin windows; do + for platform in "${all_platforms[@]}"; do if ! grep -qx "$platform.json" <<<"$present"; then missing+=("$platform") fi diff --git a/scripts/publish.mjs b/scripts/publish.mjs index 5a2a838..b5056a5 100755 --- a/scripts/publish.mjs +++ b/scripts/publish.mjs @@ -9,6 +9,12 @@ // 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 @@ -48,11 +54,13 @@ import { homebrewCask, keyProblem, nightliesNewestFirst, + PLATFORMS, platformFeed, publicRepoFrom, staleNightlies, verifyUpdateSignature, windowsFeed, + windowsInstaller, } from "./publish-lib.mjs"; import { channelOf, isNewest } from "./release-lib.mjs"; @@ -184,6 +192,16 @@ 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; diff --git a/scripts/publish.test.mjs b/scripts/publish.test.mjs index 426cc29..52f3e84 100644 --- a/scripts/publish.test.mjs +++ b/scripts/publish.test.mjs @@ -13,13 +13,17 @@ import { 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/; @@ -380,6 +384,34 @@ describe("the feed file", () => { 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, From 1c0f22dc125184b05f340c0bef8feb5c42ee64d5 Mon Sep 17 00:00:00 2001 From: Pubudu Kodikara Date: Mon, 2 Nov 2026 16:18:24 +0530 Subject: [PATCH 07/10] fix(release): build every platform at prepare's version, clean up on cancel Both build jobs now set the version the prepare job worked out (release.mjs set-version) instead of working it out again, so the rule lives in one place. A cancelled build also removes its files from the draft, and both jobs find the Windows installer with the shared rule. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 62 +++++++++++------------------------ scripts/release.mjs | 9 +++++ 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1506ec1..a5d2517 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,6 @@ jobs: 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: @@ -138,8 +137,7 @@ jobs: echo "::notice::No new commits on dev since the last nightly." build=false fi - # The same commands each build job runs with --write, on the same - # tags and start time, so every job builds this version. + # 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")" ;; @@ -273,18 +271,7 @@ jobs: - run: pnpm install --frozen-lockfile - name: Set the 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 - if [[ "$version" != "$VERSION" ]]; then - echo "::error::The Mac build would be $version, but this release is $VERSION." - exit 1 - fi + run: node scripts/release.mjs set-version "$VERSION" - name: Write the App Store Connect key env: @@ -388,10 +375,12 @@ jobs: node scripts/publish.mjs check-feed "$RUNNER_TEMP/darwin.json" --version "$VERSION" gh release upload "$TAG" "$RUNNER_TEMP/darwin.json" --clobber - # A failed 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 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() }} + if: ${{ failure() || cancelled() }} run: scripts/drop-platform.sh "$TAG" darwin - name: Remove the App Store Connect key @@ -436,18 +425,7 @@ jobs: - run: pnpm install --frozen-lockfile - name: Set the 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 - if [[ "$version" != "$VERSION" ]]; then - echo "::error::The Windows build would be $version, but this release is $VERSION." - exit 1 - fi + run: node scripts/release.mjs set-version "$VERSION" - name: Build the installer and upload it to the draft id: tauri @@ -475,12 +453,12 @@ jobs: echo "::error::Tauri built $BUILT, but this release is $VERSION." exit 1 fi - setups=("$BUNDLE"/nsis/*-setup.exe) - if (( ${#setups[@]} != 1 )) || [[ ! -f "${setups[0]}" || ! -f "${setups[0]}.sig" ]]; then - echo "::error::Expected one setup.exe with its .sig, found: ${setups[*]}" + 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=${setups[0]}" >> "$GITHUB_OUTPUT" + echo "path=$setup" >> "$GITHUB_OUTPUT" - name: Record build provenance if: ${{ needs.prepare.outputs.public == 'true' }} @@ -496,12 +474,10 @@ jobs: 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 | endswith("-setup.exe")) | .apiUrl')" - if [[ -z "$url" || "$url" == *$'\n'* ]]; then - echo "::error::Expected one setup.exe on $TAG, found: ${url:-none}" - exit 1 - fi + --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" \ @@ -510,10 +486,12 @@ jobs: node scripts/publish.mjs check-feed "$RUNNER_TEMP/windows.json" --version "$VERSION" gh release upload "$TAG" "$RUNNER_TEMP/windows.json" --clobber - # A failed 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 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() }} + if: ${{ failure() || cancelled() }} run: scripts/drop-platform.sh "$TAG" windows publish: diff --git a/scripts/release.mjs b/scripts/release.mjs index 82dedcc..137c07f 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -17,6 +17,10 @@ // --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 @@ -313,6 +317,11 @@ const main = () => { 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); break; From 9e0f8d47e87785b95542fb53e36723055171893e Mon Sep 17 00:00:00 2001 From: Pubudu Kodikara Date: Mon, 2 Nov 2026 16:18:26 +0530 Subject: [PATCH 08/10] docs: describe update feed practice without naming other products Also update a website test comment that still called Kriyon Mac-only. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0056-one-update-feed-per-platform.md | 13 ++++--------- website/src/components/Hero.test.tsx | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/adr/0056-one-update-feed-per-platform.md b/docs/adr/0056-one-update-feed-per-platform.md index e0a1d2f..c2f6d2c 100644 --- a/docs/adr/0056-one-update-feed-per-platform.md +++ b/docs/adr/0056-one-update-feed-per-platform.md @@ -17,15 +17,10 @@ file for every platform ties them together: - 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. -Other desktop apps keep platforms apart: - -- electron-builder writes `latest.yml` (Windows), `latest-mac.yml` and `latest-linux.yml`, one - per platform. -- VS Code asks its update service per platform (`/api/update///`), - and Zed passes the OS and arch to its server. -- Tauri apps Yaak (`update.yaak.app/check/{{target}}/{{arch}}/{{current_version}}`) and - GitButler (`…/releases/nightly/{{target}}-{{arch}}/{{current_version}}`) use the updater's - endpoint templating, which the Tauri docs describe for exactly this. +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 diff --git a/website/src/components/Hero.test.tsx b/website/src/components/Hero.test.tsx index 0032df0..bb2ba52 100644 --- a/website/src/components/Hero.test.tsx +++ b/website/src/components/Hero.test.tsx @@ -9,7 +9,7 @@ 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 }); From 13373cb5c086a544625a78628fbb1b18655066c0 Mon Sep 17 00:00:00 2001 From: Pubudu Kodikara Date: Mon, 2 Nov 2026 16:25:08 +0530 Subject: [PATCH 09/10] refactor(release): keep each platform's feed entries and files in one table PLATFORMS and the rule for which release files a platform's build uploads now come from the same table, so adding a platform to one without the other is no longer possible: a new platform's half-made files would otherwise never be dropped from a draft. Co-Authored-By: Claude Opus 5.5 --- scripts/publish-lib.mjs | 70 ++++++++++++++++++++--------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/scripts/publish-lib.mjs b/scripts/publish-lib.mjs index 0460ff7..d45ee24 100644 --- a/scripts/publish-lib.mjs +++ b/scripts/publish-lib.mjs @@ -10,22 +10,43 @@ import { createHash, createPublicKey, verify } from "node:crypto"; import { channelOf, compareVersions, isNightly } from "./release-lib.mjs"; +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)?)$/; + /** - * The entries each platform's feed file must have (ADR 0056): one per Mac - * chip for the universal build, and the Windows x64 installer. The keys are - * the updater's `{{target}}` values, which also name the files (darwin.json, - * windows.json). A missing entry leaves those users on an old version. + * 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 REQUIRED_ENTRIES = { - darwin: ["darwin-aarch64", "darwin-x86_64"], - windows: ["windows-x86_64"], +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, by `{{target}}` name. The release - * scripts ask `publish.mjs platforms` for this list instead of writing it out. + * 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(REQUIRED_ENTRIES); +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"; @@ -34,7 +55,7 @@ const isOf = (key, platform) => key === platform || key.startsWith(`${platform}-`); const requiredEntries = (platform) => { - const required = REQUIRED_ENTRIES[platform]; + const required = PLATFORM_RULES[platform]?.entries; if (!required) { throw new Error(`"${platform}" is not a platform Kriyon releases for`); } @@ -43,7 +64,6 @@ const requiredEntries = (platform) => { const PATH_SEPARATOR = /[\\/]/; const FEED_FILE = /^(.+)\.json$/; -const SIG_SUFFIX = /\.sig$/; /** The platform a feed file is for, from its name (`…/windows.json`). */ export const feedPlatform = (file) => { @@ -52,15 +72,12 @@ export const feedPlatform = (file) => { return "darwin"; } const platform = FEED_FILE.exec(name)?.[1]; - if (platform && Object.hasOwn(REQUIRED_ENTRIES, platform)) { + if (platform && Object.hasOwn(PLATFORM_RULES, platform)) { return platform; } throw new Error(`${file} is not a platform's feed file`); }; -/** The Windows installer, the NSIS setup.exe, by file name. */ -const WINDOWS_INSTALLER = /-setup\.exe$/; - /** * 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. @@ -80,26 +97,9 @@ export const windowsInstaller = (files) => { return found[0]; }; -const MAC_ASSET = - /^(darwin\.json|latest\.json|Kriyon(_.*)?\.dmg|.*\.app\.tar\.gz(\.sig)?)$/; - -/** The release files each platform's build uploads, by name. */ -const PLATFORM_ASSETS = { - darwin: (name) => MAC_ASSET.test(name), - windows: (name) => - name === "windows.json" || - WINDOWS_INSTALLER.test(name.replace(SIG_SUFFIX, "")), -}; - -/** - * The platform whose build uploaded the release file `name`, or undefined. - * A failed build removes its own files from the draft by this rule, and a - * release published for one platform only drops the other's. - */ +/** The platform whose build uploaded the release file `name`, or undefined. */ export const assetPlatform = (name) => - Object.keys(PLATFORM_ASSETS).find((platform) => - PLATFORM_ASSETS[platform](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) => { From a888ff9ddb50a3dc8ee35701ab16945a15e23bd0 Mon Sep 17 00:00:00 2001 From: Pubudu Kodikara Date: Mon, 2 Nov 2026 16:25:10 +0530 Subject: [PATCH 10/10] refactor(release): remove the unused --write option Build jobs set their version with release.mjs set-version now, so beta and nightly only print it. ADR 0045 and 0046 say so. Co-Authored-By: Claude Opus 5.5 --- ...ne-version-and-a-changelog-from-commits.md | 15 ++++++++------- .../0046-release-builds-and-update-feeds.md | 3 ++- scripts/release.mjs | 19 ++++--------------- 3 files changed, 14 insertions(+), 23 deletions(-) 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/scripts/release.mjs b/scripts/release.mjs index 137c07f..ddb0d59 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -9,12 +9,11 @@ // 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 @@ -283,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"); @@ -305,7 +296,7 @@ const main = () => { ); break; case "beta": - printOrWrite(betaVersion(base(), tags())); + console.log(betaVersion(base(), tags())); break; case "nightly": if (!values.run) { @@ -313,9 +304,7 @@ 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.