How a Lilo release is cut, from version bump to published DMG. Maintainer-only; day-to-day contribution flow is in CONTRIBUTING.md.
mainis green in CI andmake validatepasses locally.- The
releaseenvironment secrets listed below are configured (first release only).
Contributors record user-visible changes with make change. Changie stores each note in
.changes/unreleased/ and each prepared release in .changes/vX.Y.Z.md. Make downloads a pinned
native binary and verifies its SHA-256 checksum. Release tooling uses Python's standard library.
To prepare a release:
make release-status
make release-version
make validatemake release-status previews the next release and checks the changelog and app versions without
writing files. Fixed and Changed notes select a patch bump, Added selects minor, and Breaking
selects major. When notes request different bumps, the largest wins.
A release's changelog entry is drafted, reviewed and merged like code. It is drafted locally with
Claude Code: the changelog-draft skill in .claude/skills/ reads make changelog-context, writes a
draft, and make changelog-draft DRAFT=<file> checks it and turns the pending notes into a website story in site/src/content/releases/<version>.json, What's New
highlights in lilo-ui/src/components/whatsnew/releaseHighlights.json, and their copy in every
language catalog. Merge it before make release-version so the app ships with its highlights: the
website keeps the entry out of sight until its release is published. Review it for what only a
person can judge. The tool checks that every note has a place and quotes it exactly, not that the
story is the right one.
make release-status warns when a feature release has no entry, and make release-version refuses to
cut it: draft the entry first. To ship without one, run make release-version NO_ENTRY=1; What's New
then shows the complete notes. A missing entry can be drafted after the release with RELEASE=x.y.z; merging it
publishes the entry on the website, through the usual deployment of main. A release of fixes alone
has no entry.
Pushing the entry on a changelog/<version> branch runs the Changelog media workflow, which films
its highlights on macOS and commits them to the branch. Locally, run make changelog-media RELEASE=x.y.z
after the draft. A highlight it cannot film stays text-only, and the
release media guide explains how to fix or write its plan.
make release-version collects the pending notes into a release file, consumes the fragments, and
regenerates CHANGELOG.md. It updates MARKETING_VERSION in Xcode/Configs/Base.xcconfig, the UI and
extension bridge package versions and their lockfiles. make site-generate then builds the Astro website,
including its changelog page and app metadata, in the git-ignored site/dist/ directory.
It increments CURRENT_PROJECT_VERSION once for each release it collects, or when the app version
differs from Changie's latest release. Sparkle compares this build number, so every release needs a
larger one. It reads and checks every file before consuming the fragments, and running it again
without new fragments leaves both versions alone. Lilo/Info.plist and the About panel read the Xcode settings at build time.
For a release with visual highlights, add its translated story to
site/src/content/releases/<version>.json. Each highlight names the release notes
it describes. A story may omit its capture and show no recording. Otherwise
make release-version runs the story's capture scenario when its media is missing, then builds the website. Demo stories record local sample pages
in English and French and both appearances. Native stories use reviewed frames
from installed Lilo; make site-release-import encodes their WebP and poster.
The pilot uses a real native Peek capture on public pages. Optional actions appear
with the feature text. Review the recordings and copy with the prepared notes, and commit the
versioned assets and their capture.json source record together. Existing captures
are preserved; normal website builds and make validate do not recapture them.
See the website guide for
capture and replacement commands.
Review the generated notes and version changes, then commit them together. The initial
.changes/v0.1.5.md contains all earlier published entries, with their dates and compare links.
Edit a prepared release's file and run make release-version to regenerate the changelog.
The app bundles the prepared notes matching its marketing version during make ui-build and
shows them once after an update. Help > What’s New reopens them. New installations finish
onboarding without an additional update introduction.
Every highlight shows a visual: the drawing its illustration names in WhatsNewArt.svelte, which a
captured film replaces. make changelog-draft refuses a highlight without a drawn illustration.
To add visual highlights, add the exact version to
lilo-ui/src/components/whatsnew/releaseHighlights.json. Its headline, introduction and up to
three highlights reference keys under whatsNew.releases in every shared language
catalog. Use a benefit-led heading and one or two sentences per highlight. The static local
illustrations live in WhatsNewArt.svelte; a new illustration also needs a recognized identifier
in releaseNotes.ts. Keep illustrations decorative and communicate the change in the copy.
A highlight can show a film, a loop or an image instead: give it an id and a media kind, and
add its files under release-media/assets/.
Without an entry for that version, the sheet opens the complete notes in a compact maintenance
layout. An older version's highlights are never reused. The complete notes retain their original
English text, labelled as such in the other languages. The sheet reads its bundled release-notes.json on
opening; it makes no request to the public website or update feed.
Run make ui-preview, then open /preview/?open=whatsnew to inspect the installed release.
Add &theme=dark or &tabs=top to check another appearance or tab layout.
git tag vX.Y.Z
git push origin main vX.Y.ZPushing a v* tag triggers .github/workflows/release.yml.
The release workflow runs only in Alexays/Lilo (never in forks), on the GitHub-hosted xcode-27 image (macOS 27, Xcode 27; macos-26 has no Xcode 27):
- Checks the tag is a prepared release: its
.changes/vX.Y.Z.mdexists,MARKETING_VERSIONmatches it,make release-statuspasses, andmainalready has the tagged commit. - Restores the UI build CI cached for the same sources, or builds it with
npm ci --ignore-scripts, before any secret is in reach. The extension bridge bundles are committed and checked againstbridge-srcby CI. The checkout keeps no token. - Dates every file by its content and restores the Release build of the last
mainpush (CI'srelease-buildjob builds the same thing on every push and caches it frommain), so only what changed since compiles: a version bump alone compiles nothing. - Imports the Developer ID certificate into a temporary keychain.
- Downloads libghostty-vt (
make vt-build, checked againstVT_SHA256), then runsmake release-archive(build, export signed with Developer ID, hardened runtime and a secure timestamp). - Runs
make dmg: the pinned dmgbuild packagesbuild/release/export/Lilo.appwith the installer window fromscripts/dmg/(settings.pyand the committed background, regenerated frombackground.htmlbymake dmg-background). It then signs the DMG and notarizes it, a single submission whose ticket also covers the app inside, and staples the DMG. The app itself is not stapled: a copy taken out of the DMG and first opened offline relies on Gatekeeper's online check, and Sparkle updates download the stapled DMG. The job then checks the staple and has Gatekeeper (spctl) assess the DMG and the app on its read-only mount, so a release users would be refused never reaches the draft. - Downloads Sparkle's tools (checked against a pinned sha256) and the DMGs of the last three published releases, then generates
appcast.xmlwithgenerate_appcast, signed withSPARKLE_ED_PRIVATE_KEY. The appcast lists only the new version, with a delta update (.delta) from each of those releases: a copy one of them installed downloads the difference instead of the full DMG, and falls back to the DMG when the delta does not apply. The job warns if previous releases exist and the appcast holds no delta (generate_appcast drops a delta not clearly smaller than the DMG). The app setsSURequireSignedFeed, so both the feed and the update archives must carry a valid EdDSA signature. - Creates a draft GitHub release with the DMG, the deltas and the appcast attached, its notes taken from the tag's
.changes/vX.Y.Z.md. Installed copies read the appcast from the latest published release, so they see the update once the draft is published.
They are secrets of the release environment, which only v* tags may deploy to, so no workflow pushed on another branch can read them.
| Secret | Contents |
|---|---|
APPLE_CERT |
base64-encoded .p12 Developer ID Application certificate |
APPLE_CERT_PASSWORD |
password for the .p12 |
APPLE_NOTARY_TEAM_ID |
Apple Developer team ID (also used as DEVELOPMENT_TEAM) |
APPLE_NOTARY_APPLE_ID |
Apple ID for notarytool |
APPLE_NOTARY_PASSWORD |
app-specific password for notarytool |
SPARKLE_ED_PRIVATE_KEY |
Sparkle EdDSA private key that signs appcast.xml |
HOMEBREW_TAP_TOKEN |
fine-grained token with contents: write on Alexays/homebrew-tap, used by homebrew.yml |
DISCORD_WEBHOOK_URL |
incoming webhook URL for Lilo's Discord release channel, used by discord.yml |
In the Discord channel that should receive release notes, open Edit Channel > Integrations >
Webhooks, create a webhook named Lilo Releases, and copy its URL. In Alexays/Lilo, add it as
the DISCORD_WEBHOOK_URL Actions secret under Settings > Environments > release.
Publishing a stable vX.Y.Z GitHub release runs .github/workflows/discord.yml. It posts the published
release's notes and a link to that release, so edits made to the draft are included. GitHub's
reference-style compare links are converted for Discord. Notes longer than Discord's 4,096-character
embed limit end with a link to the full changelog. Mentions in release notes do not ping members.
Drafts, prereleases and the ghostty-vt-* tooling releases are skipped.
The job fails if the secret is missing or Discord rejects the message. Rerunning it sends another
announcement; editing the GitHub release does not send one. make release-test checks the formatting,
release filtering and error handling without contacting Discord.
Download the Sparkle release matching project.yml, then run bin/generate_keys: it stores the private key in your login keychain and prints the public key. Put the public key in SPARKLE_PUBLIC_ED_KEY (Xcode/Configs/Base.xcconfig) and commit it; export the private key with bin/generate_keys -x sparkle.key and store its contents as the SPARKLE_ED_PRIVATE_KEY secret. Builds with an empty public key never check for updates, so make release and make release-notarize refuse to build without it (make install does not need it).
make release # archive and export, no notarization
make release-notarize # same, plus notarize and staple the appscripts/build-app.sh runs xcodebuild build with an archive's postprocessing, then wraps the app in the .xcarchive that -exportArchive signs: xcodebuild archive would start from an empty build folder every time.
Both wrap scripts/build-app.sh; signing and notarization are configured via the LILO_* environment variables documented at the top of that script (LILO_TEAM_ID, LILO_CODE_SIGN_IDENTITY, LILO_NOTARY_*, and friends).
com.apple.developer.web-browser.public-key-credential lets Lilo create and use passkeys for any site. It is a restricted entitlement: Apple grants it per app ID (fr.arouillard.lilo, team 7FCQYGT54V), and a build carrying it must embed a Developer ID provisioning profile that includes it, or macOS refuses to launch the app. It is off by default, so ad-hoc, development and fork builds never carry it. The Account Holder requests it through the macOS Browsers Passkeys form.
Once Apple grants it:
- In the developer portal, enable the capability on the
fr.arouillard.liloidentifier, then create and download a Developer ID provisioning profile for it. - Point
LILO_PROVISIONING_PROFILEat that file (inMakefile.local, seeMakefile.local.example).make releaseandmake release-notarizethen embed it asContents/embedded.provisionprofileand re-sign the exported app with the entitlement added. Debug and development-signed builds ignore the variable. - Check the result before notarizing a release:
codesign -d --entitlements - build/release/export/Lilo.applists the entitlement, and the app launches. - For CI releases, store the profile as a base64 secret, decode it to a file in
release.ymland pass its path asLILO_PROVISIONING_PROFILEtomake release-archive.
- Publish the draft release. CI already checked its DMG with
staplerandspctl. - Publishing runs
.github/workflows/homebrew.yml, which bumps thelilocask of thealexays/tapHomebrew tap to the new version and the DMG's sha256, sobrew install --cask alexays/tap/liloandbrew upgradepick it up. - Publishing also runs
.github/workflows/discord.yml, which announces the release and its notes in the Discord channel configured byDISCORD_WEBHOOK_URL.
A published release reaches every copy within the hour (Sparkle checks hourly) and installs on its next quit. If it is broken:
- Set the release back to draft:
gh release edit vX.Y.Z --draft.releases/latest/download/appcast.xmlthen serves the previous release's appcast, so copies that have not updated yet stay on it. A draft's assets are not public, so the Homebrew cask, still bumped to the broken version, fails to install until the fix ships. - Sparkle never downgrades: copies already updated keep the broken version until a newer one ships. Fix the problem and release a new patch version, never the same tag again. Its deltas skip the drafted release, so those copies download the full DMG.