Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@modex/desktop",
"version": "0.2.0",
"version": "0.3.0",
"description": "Modex desktop — a Codex-App-style GUI: projects, parallel agent threads, worktrees, inline approvals, and a live Changes panel.",
"license": "MIT",
"private": true,
Expand Down
4 changes: 2 additions & 2 deletions apps/ios/ModexCompanion/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: works.jev.modex
MARKETING_VERSION: 0.2.0
CURRENT_PROJECT_VERSION: 7
MARKETING_VERSION: 0.3.0
CURRENT_PROJECT_VERSION: 8
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
INFOPLIST_FILE: Resources/Info.plist
CODE_SIGN_STYLE: Automatic
Expand Down
15 changes: 15 additions & 0 deletions docs/release-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,21 @@ rehearsal that gates the merge.
[Verifying a download](#verifying-a-download): `shasum -c`, Gatekeeper, stapler and the
signing certificate. Attach the verification log, write the notes, then publish the draft.

### Rehearsing with an rc tag

When the release workflow or the updater path changed since the last release, rehearse
before step 2. Tag the release branch's head with `git tag -s v<ver>-rc.<N>` and push only
the tag. The package version stays `<ver>`; the identity check accepts `v<ver>` or
`v<ver>-rc.<N>` (N from 1, no leading zero) and nothing else. The run is the real workflow:
the same reviewer approval, signing, notarization, packaged e2e and native acceptance. Its
assets are named for `<ver>` and land on a draft named after the rc tag. Never publish that
draft. Delete it and the rc tag once the run has told you what it needed to:

```sh
gh release delete v<ver>-rc.<N> --yes
git push origin :refs/tags/v<ver>-rc.<N> && git tag -d v<ver>-rc.<N>
```


## Native updater release gate

Expand Down
147 changes: 147 additions & 0 deletions docs/reviews/2026-10-20-v0.3.0-release-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# v0.3.0 release review

Desktop v0.3.0 ships native conversation fidelity: each turn is laid out from the CLIs' own typed
items, with truthful outcomes, evidence, plans and task records, approvals and questions answered
once, and a truthful Claude Stop. It also makes macOS updates install over the air, renders replies
as full Markdown and lets a project move to a new workspace from the chat sidebar. Val asked for
0.3.0 on 2026-10-19. iPhone Companion 0.3.0 (8) is cut from the same tag.

This preparation starts from protected `main` at `de856e0e9e33e09fd0e2f1e19e7ee4d0ba98b31e` (#318).
It changes the desktop and Companion versions, the release records, and the release identity check
so a `v<version>-rc.<N>` rehearsal tag runs the workflow again.

## Scope since v0.2.0

| Area | Pull requests |
| --- | --- |
| Native identity, phases and outcomes; turns laid out from typed items; the reader keeps its place (CF-1, CF-4) | #282, #284, #297, #333 |
| Replies render CommonMark with GitHub's extensions (part of CF-4) | #283, #333 |
| Evidence: commands, exit codes, bounded output, memory citations, full logs, Compare with working tree, web searches (CF-5) | #287, #299, #311, #328, #288 |
| Journal, replay, paired desktops and the phone carry typed state and requests (CF-6) | #285, #308, #309, #329, #330, #318 |
| Plans and task records, with clarifications (CF-7) | #289, #312 |
| Truthful Claude Stop (CF-2) | #286 |
| Approvals and questions answered once; Codex questions as cards; an answer counts as delivered once it reaches the CLI (CF-3) | #293, #335 |
| Acceptance of the conversation path, theme captures, fixes they found, live smoke (CF-8) | #294, #301, #313, #332 |
| macOS updates over the air: background download, install on quit | #317 |
| Move a project to a new workspace from the chat sidebar | #304 |
| Tooling: iPhone CI memory, Knowledge readiness test, 45-minute macOS CI job | #271, #305, #315 |
| Records | #292, #296, #300, #303, #306, #307, #310, #314, #331 |

The details are in `docs/status.md` → "On main, unreleased", which moves into this review at
publication.

## Candidate behavior and compatibility

- **Updates.** v0.2.0 still needs one **Download update** click to reach v0.3.0, then **Restart to
update**. From v0.3.0 on, a check downloads and verifies a newer release in the background, and an
update nobody restarted into installs the next time Modex quits. Windows and Linux still install
by hand. [Download and restart](../update-recovery.md#download-and-restart).
- **Transcripts.** Old transcripts read unchanged, with no invented provenance. New turns carry
origin, phase, reasoning and outcome.
- **The event journal stays opt-in** (`MODEX_EVENT_JOURNAL=1`). Its schema is additive: a v0.2.0
journal keeps its id. Downgrading keeps the file readable, but v0.2.0's phone mapping sends the new
`plan` and `question` kinds as `null` and the phone rejects that snapshot. Deleting `events.sqlite`
is always a safe rollback. [Compatibility and rollback](../event-journal.md#compatibility-and-rollback).
- **Older clients.** A paired desktop from before CF-3, such as TestFlight Store 0.0.8, gets a
question or a plan as a notice it can read. The Mac sends the phone a question as text to answer
on the Mac, so Companion 0.2.0 (7) needs no update for it. Companion 0.3.0 (8)'s only change is
#318: it reads the thread again when the Mac refuses its answer.
- **Codex questions** (`item/tool/requestUserInput`) are experimental in codex-cli 0.163.0 and
covered by scripted fixtures only.
- **Platforms.** macOS arm64 is Developer ID signed (Soul Protocol LLC) and notarized by the Release
workflow. Windows and Linux candidates are built and checked the same way as v0.2.0's. Whether
they are published is decided at publication.

## Source evidence

Every change landed through the merge guard with `unit + e2e (macOS)` green on its exact head.

## Hosted rehearsal

#317 changed the hosted native updater scenarios: they no longer click Download, and instead wait
for the automatic download of the tampered archive. That path had never run on a hosted Mac, so the
Release workflow was rehearsed on this branch before the real tag.

- The identity check required the tag to equal `v<package version>` since #186, which refused
`-rc.N` rehearsal tags. This branch accepts `v<version>-rc.<N>` (N from 1, no leading zero) for
the checked-out package version, with tests and
[Rehearsing with an rc tag](../release-signing.md#rehearsing-with-an-rc-tag).
- The signed tag `v0.3.0-rc.1` on `96da81e6` passed
[release run 39341997306](https://github.com/TypeSafeAI/modex/actions/runs/39341997306) on
2026-10-19 in 41 minutes. Every job passed:
- signing, notarization and stapling, then the packaged E2E;
- the three signed native updater scenarios;
- the Windows and Linux installer checks;
- attaching the candidates to a draft.
- In each native scenario the transport log shows the release check, then the corrupt candidate
archive fetched about a second later with no click, refused before native admission. The retry
fetched the real archive.
- **Upgrade:** Squirrel replaced the app and relaunched it as 0.3.0, with an exact-version
installed receipt and the drafts kept.
- **Native failure** and **interrupted download:** saved drafts survived and the manual
reinstall path passed.
- The rc draft and both copies of the tag were deleted afterwards. v0.2.0 stayed the latest release.
- `main` then gained #318 (the iPhone Companion and docs), #332 (a live-smoke spec that runs only
on request, and its review), #335 (CF-3 answer delivery) and #333 (CF-4 completion). #335 and
#333 change the desktop app but not its updater; the Release run on the real tag repeats every
job with them.

Install on quit is not exercised by the native scenarios, which still use **Restart to update**.
The fixture E2E covers it: the quit stages the update once and Modex does not relaunch.

## Local verification

All ran on `main` at #318 plus this branch. `main` then gained #332, a live-smoke spec that runs
only on request and its review, then #335 and #333, which change the desktop app. After each
merge, `pnpm build`, `pnpm typecheck` and `pnpm test` passed again with no failures: 1,688
desktop tests passed with #335, and 1,697 with #333, 16 skipped each time. Each passed CI's
E2E on its own head, this PR's CI runs them with the release, and the Release run repeats the
packaged E2E on the tag.

- `pnpm install --frozen-lockfile`, `pnpm build` and `pnpm typecheck`: passed.
- `pnpm test`, with no failures:

| Suite | Result |
| --- | --- |
| Scripts | 111 passed |
| Core | 16 passed |
| Desktop | 1,661 passed, 16 skipped |
| Desktop extras | 21 passed |
| Site | 10 passed |
| Store desktop | 16 passed |

- `scripts/release-mac.sh` could not reach 1Password (`authorization timeout`), so the rehearsal
ran `--sign-only`, as for v0.1.0. The Release workflow notarizes, as it did for `v0.3.0-rc.1`.
- Developer ID Application: Soul Protocol LLC, team `9LR8Z8UQ9X`, hardened runtime, JIT allowed with
library validation kept.
- Leaf certificate `EE732DF3F48D7535561AF54D3FFFC4B44DAF3E7F` on both the app and the DMG. Six nested
native Mach-O files are signed by the same team.
- The DMG checksum is valid and the ZIP is intact. Version 0.3.0, arm64, Modex icon.
- SHA-256 of these local artifacts: DMG `a3641800…b038e7`, ZIP `c7feefad…d3d60f`. The published
artifacts come from the Release workflow.
- Packaged desktop E2E against that signed app, after building the Store client: 356 passed,
4 skipped, 0 failed, then site 7/7.
- The skips are the three real-Open Knowledge cases (`MODEX_TEST_OPEN_KNOWLEDGE=1`) and live Claude
Stop, which spends real usage.
- The first run had 351 passed: four specs that drive a development browser stopped at
`Executable doesn't exist … chrome-headless-shell`, and one more did not run. Playwright's
headless shell was not installed on this Mac. After `playwright install chromium-headless-shell`,
which CI also runs, those three spec files passed 5 of 5 against the same app.

## Release gates

1. Land this preparation through the guard, then sign tag `v0.3.0` on its squash (Val's go-ahead).
2. The Release workflow, after `release-signing` approval:
- signs, notarizes and staples the app and DMG, then runs the packaged E2E;
- runs the three hosted native updater scenarios and the Windows and Linux checks;
- attaches the candidates to a draft.
3. Download the draft's assets and verify them independently:
- `shasum -c`, the codesign leaf, `spctl` for the app and the DMG, and `stapler`;
- the packaged E2E against the downloaded app.
4. Publish, then check `releases/latest` with the updater parser shipped in v0.2.0.
5. Upgrade the release Mac's v0.2.0 through its own banner (one Download click) with an unsent draft
open. Record the `app/update-recovery.json` attempt, the sha512 against the published ZIP, the
relaunch, About and the draft.
6. iPhone Companion 0.3.0 (8) from the same tag. Val is needed at the Mac for 1Password:
- `op run --env-file=.env.release -- apps/ios/ModexCompanion/Scripts/release-testflight.sh`;
- What to Test, the External group and beta review.
15 changes: 14 additions & 1 deletion docs/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The living map of what has shipped, what is on `main` but unreleased, what is in
what is next. Update it in the same PR that changes any of those; a reader should be able to
plan the next session from this page alone. Release evidence lives in [`reviews/`](reviews/).

_Last updated 2026-10-18._
_Last updated 2026-10-20._

## Shipped

Expand Down Expand Up @@ -197,6 +197,19 @@ and [landing verification](reviews/2026-10-11-landing-clarity.md).

## In flight

- **Desktop v0.3.0 and Companion 0.3.0 (8) preparation.** The release ships everything under "On
main, unreleased":
- native conversation fidelity (CF-1 to CF-8): typed items and outcomes, evidence, plans and task
records, approvals and questions answered once, and a truthful Claude Stop;
- macOS updates over the air (#317);
- full Markdown in replies, and moving a project to a new workspace from the chat sidebar.

Val asked for 0.3.0. A signed `v0.3.0-rc.1` rehearsal passed every Release job, including the
automatic download in the native updater scenarios; its draft and tag are deleted. Remaining:
the Release run on the signed tag, independent download checks, publication, the v0.2.0 → v0.3.0
banner upgrade on the release Mac, and the TestFlight upload of Companion build 8.
[Candidate review](reviews/2026-10-20-v0.3.0-release-review.md).

- **v0.2.0 after publication.** v0.2.0 was published on 2026-10-18 from the signed tag on
`01b1b8a`. Two release gates need Val at the Mac: upgrading the release Mac from v0.1.0 through
its own update banner, and uploading Companion 0.2.0 (7) to TestFlight, which carries thread
Expand Down
14 changes: 14 additions & 0 deletions scripts/test/release-candidate.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ test("release identity requires exact stable tag/package/source/run agreement",
]) assert.throws(() => release.releaseIdentity({ ...input, ...mutation }));
});

test("a rehearsal tag names the package version and an rc number", () => {
for (const tag of ["v0.0.11-rc.1", "v0.0.11-rc.12"]) assert.deepEqual(release.releaseIdentity({ ...input, tag }), { ...input, tag });
for (const tag of ["v0.0.10-rc.1", "v0.0.11-rc.0", "v0.0.11-rc.01", "v0.0.11-rc", "v0.0.11-rc.", "v0.0.11-beta.1",
"v0.0.11-rc.1-x", "v0.0.11-RC.1", "v0.0.11-rc.1" + String.fromCharCode(10), "refs/tags/v0.0.11-rc.1"]) {
assert.throws(() => release.releaseIdentity({ ...input, tag }), undefined, tag);
}
});

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -142,6 +150,12 @@ test("identity CLI binds the checked-out package and commit to the tagged workfl
assert.ok(result.stdout.trim(), "CLI must produce a verified identity");
assert.deepEqual(JSON.parse(result.stdout), { tag: env.GITHUB_REF_NAME, version: env.GITHUB_REF_NAME.slice(1), sourceSha: env.GITHUB_SHA, runId: "123" });
});
test("identity CLI accepts a rehearsal tag for the checked-out package version", () => {
const env = { ...cliEnvironment() }; env.GITHUB_REF_NAME += "-rc.2";
const result = spawnSync(process.execPath, [cli, "identity"], { cwd: project, env, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), { tag: env.GITHUB_REF_NAME, version: env.GITHUB_REF_NAME.slice(1, -"-rc.2".length), sourceSha: env.GITHUB_SHA, runId: "123" });
});
test("identity CLI refuses mismatched tags, commits, non-tag events and unsupported commands", () => {
const env = cliEnvironment();
for (const override of [{ GITHUB_REF_NAME: "v0.0.0" }, { GITHUB_SHA: "b".repeat(40) }, { GITHUB_REF_TYPE: "branch" }]) {
Expand Down
8 changes: 6 additions & 2 deletions scripts/verify-release-candidate.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import assert from "node:assert/strict";

/** The signed tag, package and workflow must describe one release candidate. */
/** The signed tag, package and workflow must describe one release candidate. A rehearsal
* tag `v<version>-rc.<N>` runs the same workflow for the same package version; its draft is
* deleted afterwards and never published. */
export function releaseIdentity({ tag, version, sourceSha, runId }) {
assert.match(version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
assert.equal(version.trim(), version);
assert.notEqual(version, "0.0.0", "Synthetic baselines cannot be released");
assert.equal(tag, `v${version}`, "Release tag must match desktop package version");
const rehearsal = `v${version}-rc.`;
assert.ok(tag === `v${version}` || (tag.startsWith(rehearsal) && /^[1-9]\d*$/.test(tag.slice(rehearsal.length))),
"Release tag must match desktop package version");
assert.match(sourceSha, /^[0-9a-f]{40}$/);
assert.equal(sourceSha.length, 40);
assert.match(runId, /^[1-9]\d*$/);
Expand Down
Loading