Skip to content

feat: let a release require its update - #1070

Merged
omartelo merged 1 commit into
mainfrom
feat/required-security-updates
Nov 9, 2026
Merged

omartelo merged 1 commit into
mainfrom
feat/required-security-updates

Conversation

@omartelo

@omartelo omartelo commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

What

A release can require its update. A > [!CAUTION] block under a release's CHANGELOG.md heading, the fourth GitHub alert kind beside IMPORTANT, WARNING and NOTE, marks the update as required. A lich behind such a release shows an update prompt with no Later and opens no new agent session until it is installed.

The required update prompt

The What's new dialog with a caution callout, and the prompt

Both frames come from a rig: this checkout's backend built as 0.86.1 with its releases URL overlaid onto a local list whose 0.87.0 carries the block, and this checkout's window on a private display.

How

Signal. patchnotes.RequiresUpdate reads a caution block out of release notes with the parser the What's new dialog already uses. release.yml needed no change: its awk copies everything under the version heading, so the block reaches the release body (run here on a section with a caution block, and the published v0.81.0 body carries its IMPORTANT block the same way). The dialog draws a caution callout in the Destructive token with its own glyph.

Detection. appupdate now reads the releases list instead of releases/latest: still one request an hour, answering both which release is the latest (the highest version in the list) and whether any release newer than the running build is required. ghrelease.Releases carries the bodies and ReleaseTags is built on it; LatestTag had no caller left and is gone. A page of 100 releases measured 2.2 MiB decoded, past the 1 MiB metadata cap, so the list has its own cap, ListLimit. AppUpdateStatus gains Required; the contract golden moves on purpose.

Enforcement.

  • The store's single session insert asks SessionRefused, so the window, lich open, the MCP tools, a handoff, a fork and a subagent card are all answered in one place. internal/spawn asks the same question before it creates a worktree, so a refused open leaves nothing behind. The window does not go through spawn.Open, which is why the gate sits at the insert.
  • The backend owns the fact: appupdate.Service remembers the last status, whoever asked for it, and RequiredUpdate answers from it for two hours before looking again itself, so a lich with no window still learns of the release and one with a window spends no extra request. A build that is not a release looks nothing up.
  • Rows that already exist are never asked: running sessions, the respawn after a restart, a resume and a session brought back from the closed list all work.
  • A shell session still opens. The prompt's Install action is a shell with the command typed into it, and that command restarts lich through the LICH_PORT and LICH_TOKEN only a lich session exports, so refusing shells would have broken the update's own path on every package-manager install.
  • The prompt for a required update has no Later, ignores lich.appUpdateDismissed, comes back when swiped away, and every hourly check raises it again once its action closed it. It keeps the ordinary action: the installer button, the install command, or the release page.
  • A session the backend refuses is now said in an error toast with the backend's own sentence (it was only logged), and "start new in the project's profile" no longer parks the old card when its replacement was refused.

Tests

Tests whose fixtures changed, and why: the appupdate fixtures (TestStatus, the Apply servers, the Windows installer end to end overlay) now serve a releases list, because the endpoint polled changed. The five LatestTag/parseTag tests went with the function; their failure cases (unreachable server, read past the cap, unbuildable URL, malformed body, non-200) are asserted on Releases and ReleaseTags.

New:

  • patchnotes: a caution block under the release banner, with CRLF, beside other kinds, absent, quoted inside a group, with no text.
  • ghrelease: bodies carried, drafts and pre-releases left out, a list larger than the metadata cap read, the list cap enforced.
  • appupdate: Required when the latest or a middle release carries the block; false when none does, when the required release is the one running or behind, on a dev build, on a build between tags, and when the lookup fails; the refusal names both versions, the install command and the release URL; the gate answers from the last lookup without another request, looks again once stale, and looks nothing up on a dev build.
  • store: every insert entry point refuses an agent session and leaves the project untouched; a shell opens; a parked session comes back; nothing is refused once the gate lifts.
  • spawn: Open, Open on a new worktree, Open from outside a session, OpenSubagent and Handoff are refused with the gate's own error and leave no worktree, row, terminal or card; a shell opens.
  • window: the prompt decision ignores Later for a required release; the required toast has no Later, keeps its action and comes back when swiped away; a check raises it again; a refused session shows a toast and keeps the card it would have replaced; the caution callout's tone.

Run against the rig backend through the command line: lich open --kind claude and lich open --kind codex --worktree ... exit 1 with the refusal and create no worktree, and lich open --kind shell opens.

Local gate, each step alone in the memory jail:

  • backend: gofmt -l . clean, go vet ./... clean, go test ./... green (47 packages), coverage 89.9% by the measure CI gates on.
  • GOOS=windows go vet -tags installer_e2e ./internal/appupdate/ clean, for the Windows-only test file this touches.
  • native/: gofmt -l . clean, go vet ./... clean, go test ./... green.

Docs

docs/ceilings.md (the signal and who understands it, no GitHub no gate, what is refused and what is not, a package-manager install still needs the command run), the Releases checklist in CLAUDE.md, the lich-changelog-i18n skill, SECURITY.md and CHANGELOG.md. The two new strings are in every locale.

@omartelo
omartelo merged commit 755b907 into main Nov 9, 2026
8 checks passed
@omartelo
omartelo deleted the feat/required-security-updates branch November 9, 2026 00:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant