# Release Reference The single reference for how a Kiro Crew release is cut, what CI does at each step, where the artifacts land, and how to verify one. Ownership boundary: [CONTRIBUTING.md](../../CONTRIBUTING.md) → "Releasing New Versions" owns the **human** process (cutting a release branch, numbering RCs, promoting, back-merging, bumping the in-code version) and the exact git commands. This file documents what the **pipeline** does once a tag exists. macOS signing mechanics and notary-credential rotation live in [signing-runbook.md](signing-runbook.md); desktop packaging lives in [desktop-app.md](desktop-app.md); the PR-time quality gates live in [../ci/ci-and-reviews.md](../ci/ci-and-reviews.md). ## The three channels | Channel | Trigger | Version shape | |---------|---------|---------------| | `nightly` | `nightly.yml`: cron `0 6 * * *` (06:00 UTC) plus manual dispatch, from `main` HEAD | `-nightly.t` | | `insider` | `release.yml`: push of a prerelease tag (`v0.2.0-rc.1`) | `-rc.N` | | `stable` | `release.yml`: push of a bare semver tag (`v0.2.0`) | `` | The channel name is a literal path segment everywhere (`cli/insider/...`, `feed/insider/...`, the `:insider` image tag), so there is no name-to-prefix mapping to get wrong. `test_publish_feed_contract.py` pins the channel set against `publish-cli.yml`, and `cli.sh` rejects anything outside it before touching the CDN. Channel is derived from the tag, and nothing else. `release.yml`'s `version` job treats **any** prerelease label as insider and maps it to a PEP 440 `rcN` wheel using the label's trailing number. Cutting the branch, numbering the RCs, deciding to promote, and merging back are human steps the pipeline knows nothing about, which is why there is no cut/promote/rollback workflow (see "Deliberately not built"). ## Workflows in the release path Every one of these exists on `main`. Two trigger workflows call the same set of reusable ones, which hold all of the build, sign, and publish logic so the recipe exists exactly once; the triggers carry only their trigger, their concurrency group, and their version derivation. | Workflow | Kind | Role | |---|---|---| | `nightly.yml` | trigger (schedule + dispatch) | Derives the date stamp, then calls everything below. `concurrency: nightly-build` with `cancel-in-progress: true`. | | `release.yml` | trigger (`push` on `v*` tags) | Derives version + channel + wheel version from the tag, calls everything below, then creates the GitHub Release. `concurrency: release-publish` with `cancel-in-progress: false` (queued). | | `dependency-vulnerability.yml` | reusable gate | `scripts/check_npm_audit.py`. Runs first; every build job needs it. | | `build-wheel.yml` | reusable build | Stamps the PEP 440 version into `pyproject.toml` and `__init__.py`, stamps the distribution channel, builds the frontend and stages it into the package, then `python -m build`. Uploads artifact `cli-wheel` (wheel + sdist). Credential-free. | | `build-desktop.yml` | reusable build | Matrix `macos-15` (universal macOS app) and `ubuntu-22.04` (AppImage) via `packaging/build-desktop.sh`. Deliberately credential-free (`contents: read` only, pinned by `test_workflow_permissions.py`), so it builds **unsigned** and hands the `.app` downstream. | | `build-windows.yml` | reusable build | `windows-latest`, an NSIS `Setup.exe`. Separate from `build-desktop.yml` because Authenticode signing has to happen *inside* the build (the installer compresses its own already-signed executable), so this job holds an AWS Signer identity and `build-desktop.yml` can stay credential-free. Callers pass `soft_fail: true`, so a Windows failure cannot skip the mac/Linux lanes. | | `publish-cli.yml` | reusable publish | Wheel + `SHA256SUMS` + KMS-signed `cli-manifest.json` to `cli///`, the same signed manifest to `feed//latest-cli.json`, and a PEP 503 index under `feed//simple/`. | | `publish-linux.yml` | reusable publish | AppImage to `desktop///`, `feed//latest-linux.yml`, then the `latest/` alias. | | `sign-and-notarize.yml` | reusable publish | Three chained jobs (`sign`, `notarize`, `publish`) covering the whole macOS trust chain and the mac feed write. | | `publish-docker.yml` | reusable publish | Multi-arch (`linux/amd64,linux/arm64`) image built from the same wheel, pushed to `ghcr.io//kirocrew`. | | `publish-installer.yml` | independent publish | Publishes `cli.sh` to the distribution bucket root. Triggered by a push to `main` touching `cli.sh` (path-filtered), plus manual dispatch. **Not** part of a channel release. | Release-adjacent, deliberately outside the release path: | Workflow | Role | |---|---| | `ota-test.yml` | End-to-end macOS auto-update proof: builds two real app versions signed with one throwaway self-signed identity in a temp keychain, serves a local feed, drives consent over the Chrome DevTools Protocol, and asserts the on-disk bundle version flips. Nightly at `40 8 * * *` plus dispatch. Proves the **swap mechanism**, not Gatekeeper acceptance. Needs no secrets. | | `docker-smoke.yml` | PR gate on the container contract (amd64, load-to-daemon, no push). | | `pages.yml` | Deploys the marketing site in `site/` to GitHub Pages on `main`, path-scoped to `site/**`. | | `ship-report.yml` | Twice-daily merged-PR summary to Slack. Not a release step. | ## Where artifacts land ### Two S3 buckets, two trust domains The **signing bucket** is private working space and never public: ``` pre-signed/// unsigned uploads from the sign job signed/// CDSigner output (CI cannot write here) notarized/// stapled, Gatekeeper-verified archive ``` The **distribution bucket** is private with BLOCK_ALL and served only through CloudFront with Origin Access Control. Two advertised hostnames alias the same distribution: `updates.crew.kiro.dev` for pointers and `download.crew.kiro.dev` for artifact bytes. Splitting the URL classes across hostnames means future protective policy on the byte surface can never touch the availability-critical feed path. ``` cli///kirocrew--py3-none-any.whl immutable cli///SHA256SUMS immutable cli///cli-manifest.json immutable desktop///KiroCrew.zip immutable desktop///KiroCrew.dmg immutable desktop///KiroCrew-x86_64.AppImage immutable desktop//latest/KiroCrew.dmg pointer, max-age=300 desktop//latest/KiroCrew-x86_64.AppImage pointer, max-age=300 feed//latest-mac.yml pointer, max-age=300 feed//latest-mac.json pointer, max-age=300 (legacy bridge) feed//latest-linux.yml pointer, max-age=300 feed//latest-cli.json pointer, no-cache feed//simple/ + feed//simple/kirocrew/ pointer, no-cache cli.sh pointer, no-cache (only root object) ``` Every public URL is exactly one of two classes, and the class decides the cache policy: - **Immutable versioned keys** are written once with `--if-none-match '*'` and cached `public, max-age=31536000, immutable`. Republishing one with different bytes leaves stale copies on some edges while a no-cache pointer is already fresh, so clients hit checksum mismatches. Every lane therefore treats a 412 (`PreconditionFailed`) as a retry: it fetches the published object through the CDN, compares sha256, continues when identical, and **fails** when the bytes differ. The version string is burned at that point; cut a new version. - **Mutable channel pointers** are plain overwrites with a short cache. Flipping a feed is the go-live action. The `feed/*` CloudFront behavior is `CACHING_DISABLED`, so the edge never caches a feed. That is not sufficient on its own: a cache policy governs CloudFront's own storage and does **not** emit a `Cache-Control` response header, and nothing else in the distribution injects one. A feed served with no freshness metadata gets *heuristically* cached by clients (roughly 10% of the object's age, so a day-old feed earns itself hours of "fresh"). Both mac feed writes therefore assert the served header through the public CDN with `curl -I` immediately after the write, and fail the job if `max-age` is missing. The check goes through the CDN rather than `s3api head-object` because the publish role is Put-only on `feed/*`; a read-back would `AccessDenied` and abort *after* the feed was already published, failing on permissions instead of on the condition it guards. `max-age=300` bounds pointer staleness at five minutes; the CLI feed uses `no-cache` instead because it is polled far less often, so revalidating always costs nothing. ### GitHub Container Registry `ghcr.io//kirocrew`, resolved from `github.repository_owner` so forks publish into their own namespace. Tag discipline mirrors the CDN keys: the **version** tag is immutable (a re-run that finds it present skips the build, verifies the existing digest already carries this repo's provenance via `gh attestation verify --signer-workflow`, and does **not** move the alias), and the **channel** alias (`nightly` / `insider` / `stable`, plus `latest` for stable) moves only after the version tag and its attestation exist. GHCR needs no AWS credentials: the push authenticates with the workflow's own `GITHUB_TOKEN`, so this lane also works on forks. The GHCR package is public, so `docker pull ghcr.io/kirodotdev/kirocrew:stable` works with no login. That is not automatic: GHCR creates every package private and inherits only *access permissions* from the linked repository, never visibility — a public repo does not imply a pullable image, and the flip is one-way (a public package cannot be made private again). Both canonical callers pass `require_public_access: true`, which arms the logged-out-pull gate proving anonymous consumers can resolve the image; a visibility regression fails the lane instead of shipping an unpullable tag. The input itself still defaults to `false` and the step is scoped to `kirodotdev`, so forks keep private packages and authenticate with a token carrying `read:packages`. ### GitHub Releases `release.yml`'s `github-release` job attaches the wheel, the sdist, the AppImage, and the two gated macOS artifacts, renamed `KiroCrew--universal-mac.zip` and `KiroCrew--universal.dmg`. It accepts macOS bytes **only** from the exact name-bound artifact the notarize job attached after the Gatekeeper gate, and re-validates them structurally before publishing (ZIP CRC plus exactly one top-level `.app`; DMG `koly` UDIF trailer). The unsigned electron-builder zip and DMG are inter-job handoffs and never become release assets. Windows `Setup.exe` is not attached. The release is marked `prerelease` when the channel is insider, and notes are generated. `github-release` is the one job that needs `contents: write`, and it is the only job that has it: the signing jobs hold AWS credentials but never `contents: write`. `test_workflow_permissions.py` pins that split. ### There is no PyPI publish Nothing in the repository publishes to PyPI, and `pip install kirocrew` from PyPI is not a supported path. `publish-cli.yml` builds a **private static PEP 503 index** per channel under `feed//simple/` and installs go through it: ```bash pip install --pre kirocrew --extra-index-url https://updates.crew.kiro.dev/feed/insider/simple/ ``` `--extra-index-url` (not `--index-url`) is deliberate: the channel index carries only `kirocrew`, so cutting off PyPI would fail on dependency resolution. pip verifies the `#sha256=` fragment on each link, giving the same fail-closed integrity as the feed. Because CloudFront with OAC does not resolve directory indexes, the workflow uploads both `.../simple/kirocrew/index.html` and the literal trailing-slash key `.../simple/kirocrew/` that pip requests, using `s3api put-object` (an `s3 cp` to a trailing-slash destination silently writes a different key). The project page is merged with the live one so prior versions stay installable, and a non-200/non-404 fetch aborts the step rather than truncating the version history. ## The macOS trust chain `sign-and-notarize.yml`, three jobs, called with `write_feed: true` by both triggers. Nothing about it is caller-specific: the trigger files carry only version derivation and `uses:` calls. 1. **sign** (ubuntu). Flattens the build artifacts, attests SLSA provenance for the wheel, sdist, and AppImage (not the mac zip or DMG, whose bytes are not final yet), uploads everything to `pre-signed/`, extracts the `.app` from the `*-mac.zip`, and submits it to CDSigner with a manifest generated at sign time from the actual bundle contents by `packaging/signing/generate-manifest.py`. `packaging/signing/sign.sh` polls every 30s with a 15-minute ceiling. `awscurl` is installed **before** AWS credentials are configured, so a drifted release of it can never observe the signing credentials. 2. **notarize** (macos-15). `notarytool submit --wait`, `stapler staple`, then a fail-closed `spctl --assess` that must report `Notarized Developer ID`. On an `Invalid` verdict the itemized Apple log is printed. The DMG is then **rebuilt from the stapled app** (`hdiutil`, plus an `/Applications` symlink), signed by a second CDSigner task with a `type: dmg` manifest, notarized, stapled, and held to the same `spctl` gate. The DMG signature is load-bearing twice over: an `hdiutil` DMG carries an adhoc signature that the Apple notary accepts but Gatekeeper treats as "no usable signature" ("app is damaged" on drag-out), and an unsigned DMG cannot be stapled at all (`stapler` Error 73), so first-install verification would need network. The stapled DMG is attested after stapling, because stapling changes the shipping bytes. The job ends by attaching the gated artifact, which is the sole input of everything downstream. The Apple credential is fetched from AWS Secrets Manager at runtime, masked, scoped to single steps in this job, and never written to `GITHUB_ENV`, a file, or a log. 3. **publish** (ubuntu). Copies the gated zip and DMG to the distribution bucket, writes `latest-mac.yml`, writes the legacy `latest-mac.json` bridge, then the human `latest/KiroCrew.dmg` alias. Separate from notarize so a transient S3 failure retries as a two-minute ubuntu job instead of repeating two Apple submissions, and so the expensive macOS runner never burns minutes on uploads. Its `if:` starts with `success()`, which is required: a custom job-level `if` replaces the implicit success check, and without it the job would run after a failed or skipped notarize. Linux publishing is deliberately not in this workflow: the AppImage takes no part in the macOS trust chain, so `publish-linux.yml` is its own lane. Linux has no Gatekeeper equivalent and the AppImage is not code-signed by design; it ships with its own in-lane SLSA provenance, attested before anything reaches S3, so a CDN-served AppImage always carries verifiable provenance even when the macOS workflow fails or is cancelled. ### Go-live ordering Within every lane the order is fixed: versioned immutable bytes first, then the feed, then the convenience `latest/` alias. A feed written before its artifacts would hand clients a 403; an alias written before the feed would point ahead of the go-live switch. Before writing a feed, the mac and Linux lanes re-fetch the just-published object **through the public CDN** and compare its sha512 against the digest the feed is about to advertise, failing closed on a mismatch. That is what makes the tolerated 412 safe: a same-version re-run whose artifact differs byte-for-byte would otherwise leave the old object published while the feed described the new one, and every client would refuse to install. Ordering **across** runs is protected only by the trigger workflows' `concurrency` groups (nightly cancels an in-flight older run, release queues), not by a version comparison at write time, which would itself be a read-then-write race. ## Version stamping The in-code `__version__` in `src/kiro_crew/__init__.py` is the source of truth for non-tag builds. A tagged release overrides all three manifests at build time. See CONTRIBUTING.md → "Bumping the in-code version" for the three files and why the base must stay a bare `X.Y.Z`. | Channel | Desktop / semver stamp | CLI wheel (PEP 440) | |---------|------------------------|---------------------| | nightly | `0.2.0-nightly.20260708t061155` | `0.2.0.dev20260708061155` | | insider | `0.2.0-rc.1` | `0.2.0rc1` | | stable | `0.2.0` | `0.2.0` | Two stamps exist because the consumers disagree: Squirrel and electron-builder need semver, the wheel needs PEP 440. `nightly.yml` reads the clock **once** and slices it, because three separate `date -u` calls can straddle UTC midnight and pair an old-day date with a new-day time, which would move the version backward. The nightly semver shape is not cosmetic. Date and time are **one alphanumeric identifier separated by `t`** (`t`), never a bare 14-digit run and never two dot-separated numeric identifiers. Two independent constraints force it, both proven live on `windows-latest`: - Squirrel.Windows derives each release entry's version from the nupkg *filename* and Int32-parses digit runs when sorting, so a run above 2147483647 makes `Update.com --releasify` die with an overflow. The bound is magnitude, not digit count. Dot-splitting does not help: electron-builder concatenates the identifiers back into the filename. A letter between the digit runs is what survives that concatenation. - SemVer forbids leading zeros in a purely numeric prerelease identifier, and the 06:00 cron yields `HHMMSS=060000`. Inside an alphanumeric identifier the leading zero is legal. Ordering still works: the identifier is fixed-width and semver compares alphanumeric identifiers lexically, which for a zero-padded `YYYYMMDDtHHMMSS` is chronological. The `-nightly.` prefix is load-bearing (`auto-update.js` `channelForVersion`, the instance guard's `identityFamily`, and `packaging/build-desktop.sh`'s `*-nightly.*` glob all match on it). `test/test_nightly_version_contract.py` pins every property above. Seconds precision exists so no published key is ever overwritten: a date-only stamp let two nightlies on one UTC date collide on the same `signed/`, `notarized/`, and `cli/` keys. **One collision trap:** any two prerelease tags sharing a base and a trailing number collapse onto the same PEP 440 wheel version, because `release.yml` maps by trailing number alone. `v0.2.0-rc.1` and `v0.2.0-insider.1` both map to `0.2.0rc1`, and the second publish fails as a republish of an immutable key. Stick to one convention (`-rc.N`) per base version. ## CLI channel and the signed manifest The wheel is a first-class channel target, not a byproduct: a Linux or EC2 host tracks nightly, insider, or stable and installs from the same feed shape the desktop uses. `publish-cli.yml` depends only on the built wheel and its own KMS key, never on Apple or CDSigner, so a macOS signing failure cannot block a CLI release. The same independence holds for `publish-linux.yml` (needs only `build-desktop`) and `publish-docker.yml` (needs only the wheel). `SHA256SUMS` sits beside the wheel for legacy tooling, but it is only a corruption check. Authenticity comes from a canonical JSON artifact manifest signed with a non-exportable RSA KMS key: ```json { "algorithm": "RSASSA_PKCS1_V1_5_SHA_256", "channel": "insider", "key_id": "sha256:", "pub_date": "2026-07-18T06:15:00Z", "python_requires": ">=3.10", "schema": "kirocrew-cli-artifact-manifest-v1", "sha256": "", "signature": "", "version": "0.2.0", "wheel_url": "https://download.crew.kiro.dev/cli/insider/0.2.0/kirocrew-0.2.0-py3-none-any.whl" } ``` The signature covers sorted compact UTF-8 JSON of every field except `signature`. The four signature fields are additive to the six legacy ones, so older installers stay parse-compatible while a strict installer authenticates the same object. `cli.sh` embeds the public key and its expected `key_id` (the SHA-256 of the PEM's DER encoding). Before any network I/O it requires OpenSSL, refuses an unconfigured pin, materializes the key, and checks that fingerprint. It then reconstructs the canonical bytes and verifies the signature, applies bounded sizes plus duplicate-key and exact-field-set rejection, and validates the authenticated channel, version, digest and canonical wheel URL against what was requested, so even a valid signer cannot redirect the installer to another origin. Only then does it fetch wheel bytes, and it re-checks them against the signed digest. Missing key, missing signature, malformed or duplicate fields, wrong key id, bad signature, redirect metadata, and digest mismatch all refuse installation. There is no unsigned fallback. Publication is configured as one unit: `publish-cli.yml` fails **before any upload** when exactly one of `secrets.AWS_SIGNING_ROLE_ARN` and `vars.CLI_MANIFEST_SIGNING_KEY_ARN` is set, rather than publishing an unsigned manifest, and skips entirely when neither is (fork or feature branch). It also refuses when `CLI_DIST_BUCKET` or `CLI_CDN_BASE` is unset, because the origin bucket is private and a manifest must never advertise a raw S3 URL. `pub_date` is derived from the source commit's timestamp rather than wall clock, so a retried job produces a byte-identical manifest and the immutable write still succeeds. Key provisioning, the `kms:GetPublicKey` + `kms:Sign` grant, and the rotation procedure (dual-trust, never an in-place swap, because schema v1 pins exactly one key) are in [../../packaging/signing/README.md](../../packaging/signing/README.md). `publish-installer.yml` mechanically enforces the rollout order rather than trusting it. It publishes only from `main` (checked explicitly, because `workflow_dispatch` lets a maintainer pick any ref and `environment: prod` does not constrain that), checks out `main`'s tip rather than the event SHA (one live copy, whose only correct content is latest reviewed `main`), fails loudly on missing configuration instead of skipping green, refuses to publish an installer that still pins `CLI_MANIFEST_KEY_ID="UNCONFIGURED"` or whose pinned id does not match its embedded key, and refuses unless **every live channel feed** verifies against that pinned key using the same `cli-manifest.py verify` checks the installer runs. A channel serving no feed at all is skipped with a warning, since publishing is not a regression for it. ### Installing and switching channels ```bash # install, or move to another channel curl -fsSL https://download.crew.kiro.dev/cli.sh | sh -s -- --channel {nightly|insider|stable} ``` The installer resolves the channel feed, verifies it as described above, installs with `pipx` when available (otherwise a managed venv beside the data home), and records the channel in `~/.kiro/crew/channel`. Default channel is `stable`; `KIROCREW_CHANNEL` overrides it, and `--version` pins an exact wheel through the immutable `cli///cli-manifest.json` instead of the mutable feed. This download path is separate from the source install (`install.sh`, a git clone plus `pip install -e .`), which is what `kirocrew update` refreshes: that command needs a git checkout at `KIROCREW_PROJECT_DIR` and runs `git fetch` plus `git reset --hard` (checking a governance source pin on the remote URL first, so the fleet, not the human at the terminal, decides which remote a host may take code from), then rebuilds the frontend, reinstalls with `pip install -e .`, and re-runs `setup --agent-only`. The dashboard's `POST /api/update` performs the equivalent and restarts the gateway; neither path consumes the channel feed. ## Client auto-update The desktop updater is `electron-updater` in `website/electron/auto-update.js`. It runs in packaged macOS and Linux builds only: `SUPPORTED_PLATFORMS` is exactly `{darwin, linux}`. The NSIS target removes the packaging blocker, since electron-updater's win32 path is `NsisUpdater`, but win32 stays out until a `latest.yml` feed is published and Authenticode signing is active: `NsisUpdater` verifies signatures fail-closed, so an unsigned installer would make every update fail rather than warn. On macOS electron-updater's `MacUpdater` downloads the archive itself and serves it to Electron's built-in `autoUpdater` (Squirrel.Mac) over a loopback proxy, so the atomic bundle swap is unchanged and `NSURLCache` is no longer in the feed path. On Linux it replaces the AppImage in place. The client resolves `{feedBase}/{channel}/` as a **directory** (the trailing slash matters: without it `new URL("latest-mac.yml", base)` replaces the last segment and resolves the wrong channel) and the library appends the platform filename. The feed base defaults to `https://updates.crew.kiro.dev/feed` and is overridable through `KIROCREW_UPDATE_FEED`, which enforces HTTPS except on loopback so the local harness works. The yml lives on the pointer host while `files[].url` entries are absolute byte-host URLs; electron-updater's `newUrlFromBase` ignores the base for absolute URLs, which is what preserves the split. First check runs 30s after launch, then every 4 hours. Feed shape (electron-updater channel metadata, exactly what electron-builder generates). `sha512` is **base64 of the raw digest**, never hex, because electron-updater string-compares it and a hex value fails every download: ```yaml version: 0.1.0-nightly.20260721t061155 files: - url: https://download.crew.kiro.dev/desktop/nightly/0.1.0-nightly.20260721t061155/KiroCrew.zip sha512: '' size: 123456789 - url: https://download.crew.kiro.dev/desktop/nightly/0.1.0-nightly.20260721t061155/KiroCrew.dmg sha512: '' size: 234567890 path: https://download.crew.kiro.dev/desktop/nightly/0.1.0-nightly.20260721t061155/KiroCrew.zip sha512: '' releaseDate: '2026-07-21T06:22:13Z' ``` The zip is the update payload (the updater's `findFile` skips dmg/pkg); the DMG entry is listed for tooling parity and stays the human first-install download, which is why it also gets its own `desktop//latest/KiroCrew.dmg` permalink. Downloads are verified fail-closed against the feed's `sha512` before install, and on macOS Squirrel.Mac additionally validates the swapped bundle's code signature, which is precisely why the feed may only ever point at signed artifacts. `feed//latest-mac.json` is a transition bridge for installs fielded before the electron-updater migration, which poll that flat JSON and know nothing about the yml. It advertises the same version and the same bytes, so an old install updates once and never reads it again. Deleting it would strand those installs permanently with a manual DMG re-download as the only escape. `test_publish_feed_contract.py` pins it so it cannot be dropped silently; it is safe to remove once no pre-migration installs remain. Four updater policy flags each differ from the library default on purpose: `autoDownload=false` (consent-first: discovery must never pull megabytes), `autoInstallOnAppQuit=false` (the default would swap the bundle on quit without stopping the embedded Python gateway), `allowDowngrade=true` (the gate is difference-based, so a feed pointed at an older version is offered, which is what makes a channel switch-back work), and `allowPrerelease=true` (every nightly and insider stamp is a semver prerelease and would otherwise be invisible to its own channel). The library still refuses an equal version before the `allowDowngrade` branch, which is what prevents a self-reinstall loop. The specific to Kiro Crew part is install ordering: the app supervises a bundled Python gateway child, so before `quitAndInstall` the client stops it gracefully (`POST /api/shutdown`, then SIGTERM, then SIGKILL) and disarms the liveness watchdog that would otherwise resurrect it mid-swap. Choosing "Later" defers to natural quit through a `before-quit` hook in the same stop-gateway-first order. ## Windows `build-windows.yml` builds and **Authenticode-signs** the NSIS `Setup.exe` through AWS Signer during the build (signing profile `KiroCrewWindowsExe`), whenever `AWS_WINDOWS_SIGNING_ROLE_ARN` is present and the caller passed `use_prod_environment: true`. The lane is **installer-only**: it publishes nothing, and electron-builder emits the installer flat into `dist/`. Because no publish lane consumes them, the artifacts are not attested yet; provenance will land in-lane the way `publish-linux.yml` does it. win32 auto-update stays disabled in the client. The supported Windows install path is source: see [../guides/windows-install.md](../guides/windows-install.md). `build-desktop.yml`'s matrix also leaves Linux arm64 open. A new platform lane needs: a matrix entry with a stable `{os}-{arch}` id; two artifact roles (a first-install installer and an update archive the platform updater consumes, both from the standard desktop packaging path); artifacts carrying the stamped version; staging only to `pre-signed/` and only through the publish role; the platform's native signing verified fail-closed before any artifact becomes client-visible; a `feed//latest-.yml` in the electron-updater shape with absolute byte-host URLs; a client updater that honors the gateway stop, the "Later" deferral, and platform-native signature validation of the download; the platform added to `SUPPORTED_PLATFORMS`; a working roll-forward path (a lane whose updater cannot pick up a newer version has no recovery story); and channel-appropriate retention so nightlies do not accumulate unbounded. ## Identity and trust boundaries CI holds no static cloud credentials. Every AWS interaction is short-lived OIDC. The publish role's OIDC trust accepts exactly two subjects: `ref:refs/heads/main` and `environment:prod`. Release runs are tag-triggered (`ref:refs/tags/v*`), which is **not** trusted, so every publishing job declares `environment: prod`, which switches the caller's subject. This is not optional plumbing: it is why `publish-cli.yml`, `publish-linux.yml`, `publish-installer.yml`, and all three jobs in `sign-and-notarize.yml` name the environment, and why `build-windows.yml` takes `use_prod_environment` as an input rather than deriving it (inside a called reusable workflow the `github` context reports the *caller's* trigger and never `workflow_call`, so an `event_name` test would leave the environment unset on exactly the paths that need it). CI cannot write `signed/*`. Only the CDSigner service principal's role can, which is what makes "signed artifacts originate from the signer" structural rather than procedural. `publish-docker.yml` takes no `secrets: inherit`. It authenticates with `GITHUB_TOKEN` alone, and inheriting would expose every signing and CDN secret to a lane documented as needing none. `packages: write` is scoped to that one job. Full account, role, endpoint, and credential-rotation detail is in [signing-runbook.md](signing-runbook.md). ## Verifying a release Each lane self-verifies through the public CDN before it reports success, which is the check that matters: "uploaded" is not "live and correct". - Immutable keys: sha256 compare on a 412, so a re-run can never diverge from what is published. - Feeds: sha512 of the CDN-served artifact must equal the digest the feed is about to advertise, and the served `Cache-Control` must carry `max-age`. - `cli.sh`: sha256 of the CDN-served script must equal the published bytes, with retries for edge revalidation, and the header must be `no-cache`. The script is also `sh -n` parsed and must reject an unknown channel before reaching the CDN. - Docker: a version tag that already exists must carry provenance attested by this repository's `publish-docker` workflow, verified with `--signer-workflow`, before the run treats it as a valid prior publish. Manual spot-check of a channel after a release: ```bash CH=stable BYTES=https://download.crew.kiro.dev PTR=https://updates.crew.kiro.dev curl -fsSI "$BYTES/desktop/$CH/latest/KiroCrew.dmg" | head -1 curl -fsSI "$BYTES/desktop/$CH/latest/KiroCrew-x86_64.AppImage" | head -1 curl -fsS "$PTR/feed/$CH/latest-mac.yml" curl -fsS "$PTR/feed/$CH/latest-linux.yml" curl -fsS "$PTR/feed/$CH/latest-cli.json" > /tmp/feed.json curl -fsS "$PTR/feed/$CH/simple/kirocrew/" | head -5 # authenticate the CLI feed with the same checks cli.sh runs python3 packaging/signing/cli-manifest.py verify \ --manifest /tmp/feed.json \ --public-key packaging/signing/cli-manifest-public.pem \ --expected-channel "$CH" \ --artifact-base "$BYTES" ``` For the desktop swap itself, `ota-test.yml` is the end-to-end proof; run it on demand after a change to the updater. It validates the swap mechanism, not Gatekeeper acceptance, since it signs with a throwaway identity. ## Recovery: roll forward **There is no rollback.** The recovery path for a bad release is to cut a new version from the release branch and let the channel feed advance to it. Published CDN keys are immutable and are never overwritten, so there is nothing to revert in place, and every lane refuses a same-version republish with different bytes. The client capability to *accept* an older version exists (`allowDowngrade=true`, so a feed repointed backward would be offered), but repointing is not the operational answer: it fights the immutable-key discipline and the concurrency groups that exist to stop a channel rolling backward. Practical consequences when something goes wrong mid-release: - A failed publish step re-runs safely. Immutable writes are idempotent on identical bytes and abort on different bytes; the mac `publish` job is a cheap ubuntu retry that does not repeat Apple submissions. - A re-run of an **older** release never moves a channel forward or backward: the Docker lane refuses to move the alias, and the S3 lanes refuse a divergent republish. - A Docker run that died between its version-tag push and its attestation leaves an unattested digest that later runs will refuse. Delete that version tag in the GHCR package settings and re-run to rebuild and attest cleanly. - A stable Docker run that died between its `stable` and `latest` writes is repaired automatically, but only when `stable` already resolves to this run's digest, so the repair can only converge `latest` toward `stable`. ## Changelog Every release lands a `## [X.Y.Z] — YYYY-MM-DD` section in `CHANGELOG.md` through a normal PR, alongside any version bump. The section format (ordering, tone, contributor lines) is specified once in [AGENTS.md](../../AGENTS.md) → "Release Changelog". The dashboard reads the changelog from `KIROCREW_PROJECT_DIR/CHANGELOG.md` for source installs and from the bundled copy inside the package for wheel installs. ## Deliberately not built These names appear in older design material and in code comments that point here. None of them exists, and the omissions are decisions, not gaps. | Not built | Why | |---|---| | `beta-cut.yml`, `beta-hotfix.yml`, `promote-stable.yml` | Cutting a branch, numbering RCs, and promoting are human steps. The pipeline reacts only to a pushed tag. | | `rollback.yml`, `blocked-versions.json` | There is no rollback. Recovery is a new version. | | A feed Lambda writing the channel pointer on an S3 PUT event | A PUT event cannot express "and signature verification passed". CI writes the feed synchronously, after the Gatekeeper gate. No Lambda is deployed. | | `latest-mac.json` as the *primary* feed, with CloudFront Function query routing (`?channel=X&platform=Y`) | Static electron-updater channel files fetched directly, with client-side version compare. The `latest-mac.json` that exists is a legacy bridge, not a routing scheme. | | A `beta` channel or path segment | The channel is `insider` everywhere, including the storage prefix. `cli.sh` must never remap it: a remapped prefix was never published and surfaces as an opaque CDN 403. | | A forced minimum version floor | Not built. A feed-served floor that force-triggers the update flow for a critical patch remains open. | | A fixed promote cadence | Insider bakes until judged stable. There is no calendar commitment. |