From d6fc1f6cf2f4a45307d1e3bd3bda442fcbf0620f Mon Sep 17 00:00:00 2001
From: Krish Garg
Date: Sat, 10 Oct 2026 03:25:33 -0700
Subject: [PATCH 01/10] Release: stable v* releases marked Latest, daily
nightly prereleases of main
A pushed v tag now publishes a stable release: not a prerelease,
marked Latest, titled "Insomnia (stable)". A new nightly channel
publishes the head of main once a day (07:17 UTC) as a prerelease that is
never Latest, under a new tag nightly--, with the zip named Insomnia--nightly---
macos.zip. A manual run builds the artifact only, as before, unless its new
nightly input is set, which runs the nightly path and refuses any ref but
main.
A read-only plan job picks the channel. For a nightly it lists the
nightly-* tags and the releases: a commit that already has a nightly is
skipped before anything is built, a tag without a release or on another
commit stops the run. The nightly job creates the tag with the API call
that fails when the ref exists, so no tag is ever moved; a rerun of its
own failed job finds its tag on the same commit. Neither publishing job
can replace a published release or asset.
Both channels keep the same tests, build, checksum and attestation steps,
the pinned actions and per-job permissions (plan and build read only).
The notes' verify command adds --source-digest, and its --source-ref is
the tag or refs/heads/main, so a nightly never passes for a stable tag.
README's Install section and docs/releasing.md let readers and coding
agents choose stable or nightly, and send v0.1.0, which was packaged by
hand from the maintainer's installed app and has no attestation, to its
own release notes. SECURITY.md and docs/release-validation.md describe
both channels and the v0.1.0 exception; both channels' end-to-end runs
are recorded as not run. ReleaseWorkflowTests cover the triggers, gates,
release flags, tag creation and the README's channel choice.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.github/workflows/release.yml | 246 ++++++++++++++++--
README.md | 86 ++++--
SECURITY.md | 26 +-
.../InsomniaTests/ReleaseWorkflowTests.swift | 183 ++++++++++---
docs/release-validation.md | 28 +-
docs/releasing.md | 143 +++++++---
6 files changed, 568 insertions(+), 144 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 45da981f..036ad081 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,52 +1,145 @@
name: Release
-# Builds the downloadable Insomnia package and, for a version tag, publishes
-# it as a GitHub Release. docs/releasing.md walks through a release.
+# Builds the downloadable Insomnia package and publishes it on one of two
+# channels. docs/releasing.md walks through both.
#
# Triggers
-# push of a tag v test, build, package, attest, publish a GitHub
-# Release. must equal
-# CFBundleShortVersionString in Resources/Info.plist.
+# push of a tag v stable: test, build, package, attest, publish a
+# GitHub Release marked Latest. must
+# equal CFBundleShortVersionString in
+# Resources/Info.plist.
+# schedule, daily nightly: the same for the head of main, published
+# as a prerelease that is never Latest, under a new
+# tag nightly--. A commit that already has a nightly
+# is skipped before anything is built.
# workflow_dispatch test, build and package only; the zip and
# SHA256SUMS are uploaded as a workflow artifact.
-# Nothing is attested or published.
+# Nothing is attested or published. With the
+# nightly input set, it is a nightly run instead,
+# and only on main.
#
# The bundle is ad-hoc signed and not notarized (build-app.sh), so the run
-# uses no secrets and no keychain. The release is a prerelease. Every action
-# is pinned to a commit.
+# uses no secrets and no keychain. Every action is pinned to a commit.
on:
push:
tags: ['v*']
+ schedule:
+ # 07:17 UTC. GitHub delays scheduled runs most at the start of the hour.
+ - cron: '17 7 * * *'
workflow_dispatch:
+ inputs:
+ nightly:
+ description: Publish a nightly prerelease of this commit (main only; skipped if it already has one)
+ type: boolean
+ default: false
# Nothing at the top level; each job declares exactly what it needs.
permissions: {}
-# One run per tag; a rerun waits instead of cancelling a publish in progress.
+# One run per ref; a rerun waits instead of cancelling a publish in progress.
+# Scheduled and manual runs on main share a group, so two nightlies of one
+# commit never race.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
+ plan:
+ name: Choose the channel
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ # Reads tags and releases to skip a commit that already has a nightly.
+ permissions:
+ contents: read
+ outputs:
+ channel: ${{ steps.plan.outputs.channel }}
+ build: ${{ steps.plan.outputs.build }}
+ tag: ${{ steps.plan.outputs.tag }}
+ steps:
+ - name: Choose the channel, and the tag for a nightly
+ id: plan
+ env:
+ GH_TOKEN: ${{ github.token }}
+ EVENT: ${{ github.event_name }}
+ NIGHTLY_INPUT: ${{ inputs.nightly }}
+ run: |
+ case "$EVENT" in
+ push)
+ # The trigger matches v* tags only.
+ printf 'channel=stable\nbuild=true\n' >> "$GITHUB_OUTPUT"
+ exit 0
+ ;;
+ workflow_dispatch)
+ if [[ "$NIGHTLY_INPUT" != true ]]; then
+ printf 'channel=artifact\nbuild=true\n' >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ ;;
+ schedule) ;;
+ *)
+ echo "unexpected event $EVENT" >&2
+ exit 1
+ ;;
+ esac
+ if [[ "$GITHUB_REF" != refs/heads/main ]]; then
+ echo "A nightly is built from main only, not $GITHUB_REF. Nothing was built or published." >&2
+ exit 1
+ fi
+ sha12="${GITHUB_SHA:0:12}"
+ # Nightly tags of this commit. The workflow makes lightweight
+ # tags, so each ref names the commit itself.
+ refs="$(gh api --paginate "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/nightly-" --jq '.[] | "\(.ref) \(.object.sha)"')"
+ releases="$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" --jq '.[].tag_name')"
+ while read -r ref sha; do
+ [[ "$ref" =~ ^refs/tags/nightly-[0-9]{8}-${sha12}$ ]] || continue
+ tag="${ref#refs/tags/}"
+ if [[ "$sha" != "$GITHUB_SHA" ]]; then
+ echo "tag $tag points at $sha, not $GITHUB_SHA. Nothing was built or published." >&2
+ exit 1
+ fi
+ if grep -qxF "$tag" <<< "$releases"; then
+ echo "$GITHUB_SHA already has the nightly $tag. Nothing to build."
+ printf 'channel=nightly\nbuild=false\n' >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "tag $tag exists but has no release. Rerun the failed jobs of the run that made it, or delete the tag; nothing was built or published." >&2
+ exit 1
+ done <<< "$refs"
+ tag="nightly-$(date -u +%Y%m%d)-$sha12"
+ echo "nightly $tag for $GITHUB_SHA"
+ printf 'channel=nightly\nbuild=true\ntag=%s\n' "$tag" >> "$GITHUB_OUTPUT"
+
build:
name: Test, build and package (macOS 26)
+ needs: plan
+ if: needs.plan.outputs.build == 'true'
runs-on: macos-26
timeout-minutes: 60
- # Read-only: only the release job below may write.
+ # Read-only: only the publishing jobs below may write.
permissions:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
+ package: ${{ steps.version.outputs.package }}
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- - name: Read the version and check the tag against it
+ - name: Read the version, check the tag against it and name the package
id: version
+ env:
+ CHANNEL: ${{ needs.plan.outputs.channel }}
+ NIGHTLY_TAG: ${{ needs.plan.outputs.tag }}
run: |
+ head="$(git rev-parse HEAD)"
+ if [[ "$head" != "$GITHUB_SHA" ]]; then
+ echo "checked out $head, not $GITHUB_SHA" >&2
+ exit 1
+ fi
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Resources/Info.plist)"
case "$GITHUB_REF" in
refs/tags/v*)
@@ -57,8 +150,18 @@ jobs:
fi
;;
esac
+ package="Insomnia-$version-macos"
+ if [[ "$CHANNEL" == nightly ]]; then
+ want="^nightly-[0-9]{8}-${GITHUB_SHA:0:12}\$"
+ if [[ ! "$NIGHTLY_TAG" =~ $want ]]; then
+ echo "nightly tag '$NIGHTLY_TAG' does not name $GITHUB_SHA" >&2
+ exit 1
+ fi
+ package="Insomnia-$version-$NIGHTLY_TAG-macos"
+ fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- echo "Insomnia $version"
+ echo "package=$package" >> "$GITHUB_OUTPUT"
+ echo "Insomnia $version, $CHANNEL, package $package"
- name: Print toolchain versions
run: |
@@ -98,9 +201,9 @@ jobs:
- name: Package
env:
- VERSION: ${{ steps.version.outputs.version }}
+ PACKAGE: ${{ steps.version.outputs.package }}
run: |
- pkg="Insomnia-$VERSION-macos"
+ pkg="$PACKAGE"
mkdir -p "release/$pkg"
/usr/bin/ditto dist/Insomnia.app "release/$pkg/Insomnia.app"
cp scripts/install.sh scripts/uninstall.sh "release/$pkg/"
@@ -113,14 +216,14 @@ jobs:
- name: Upload the package as a workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
- name: Insomnia-${{ steps.version.outputs.version }}-macos
+ name: ${{ steps.version.outputs.package }}
path: |
release/*.zip
release/SHA256SUMS
if-no-files-found: error
- release:
- name: Attest and publish the GitHub Release
+ stable:
+ name: Attest and publish the stable release
needs: build
# Pushed tags only. A manual run stops at the artifact, even one started
# on a tag ref, which a check of the ref alone would let through.
@@ -137,7 +240,7 @@ jobs:
- name: Download the package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
- name: Insomnia-${{ needs.build.outputs.version }}-macos
+ name: ${{ needs.build.outputs.package }}
path: release
- name: Attest build provenance
@@ -149,7 +252,7 @@ jobs:
run: |
zip="$(basename release/*.zip)"
sum="$(cut -d' ' -f1 release/SHA256SUMS)"
- signing="Prerelease: ad-hoc signed and not notarized, so macOS blocks the first launch until you allow it in System Settings > Privacy & Security. The checksum and attestation below show this zip is what the Release workflow built from the tagged commit."
+ signing="Stable release. Ad-hoc signed and not notarized, so macOS blocks the first launch until you allow it in System Settings > Privacy & Security. The checksum and attestation below show this zip is what the Release workflow built from the tagged commit."
install_note="The installer checks the bundle's signature, identifier and version before asking for your password, but it cannot verify where this bundle came from: that is what the checksum and the attestation above are for, and \`--allow-unverified-origin\` says you checked them. It then installs the app, the recovery agent and the sudoers rule described in the README."
{
echo "Insomnia $VERSION for Apple Silicon Macs with macOS 26 or later. The app is built for arm64 only, so the installer stops on an Intel Mac."
@@ -160,10 +263,10 @@ jobs:
echo
echo '```bash'
echo "shasum -a 256 -c SHA256SUMS"
- echo "gh attestation verify $zip -R $GITHUB_REPOSITORY --signer-workflow $GITHUB_REPOSITORY/.github/workflows/release.yml --source-ref $GITHUB_REF"
+ echo "gh attestation verify $zip -R $GITHUB_REPOSITORY --signer-workflow $GITHUB_REPOSITORY/.github/workflows/release.yml --source-ref $GITHUB_REF --source-digest $GITHUB_SHA"
echo '```'
echo
- echo "The second command checks that this zip is the subject of an attestation signed by this repository's Release workflow for the tag \`$GITHUB_REF_NAME\`, not by just any workflow of this repository."
+ echo "The second command checks that this zip is the subject of an attestation signed by this repository's Release workflow for the tag \`$GITHUB_REF_NAME\` at commit \`$GITHUB_SHA\`, not by just any workflow of this repository, and not for a nightly build of main."
echo
echo "SHA-256 of \`$zip\`: \`$sum\`"
echo
@@ -188,7 +291,104 @@ jobs:
run: |
gh release create "$GITHUB_REF_NAME" release/*.zip release/SHA256SUMS \
--repo "$GITHUB_REPOSITORY" \
- --title "Insomnia $VERSION" \
+ --title "Insomnia $VERSION (stable)" \
+ --notes-file notes.md \
+ --verify-tag \
+ --latest
+
+ nightly:
+ name: Attest and publish the nightly prerelease
+ needs: [plan, build]
+ # Scheduled runs, and manual runs with the nightly input, on main only;
+ # the plan job has already refused any other ref.
+ if: needs.plan.outputs.channel == 'nightly' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ permissions:
+ contents: write # create the tag and the release, upload its assets
+ id-token: write # sign the provenance statement (Sigstore)
+ attestations: write # store the attestation on the repository
+ env:
+ VERSION: ${{ needs.build.outputs.version }}
+ TAG: ${{ needs.plan.outputs.tag }}
+ steps:
+ - name: Download the package
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: ${{ needs.build.outputs.package }}
+ path: release
+
+ - name: Attest build provenance
+ uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
+ with:
+ subject-path: release/*.zip
+
+ - name: Write the release notes
+ run: |
+ zip="$(basename release/*.zip)"
+ sum="$(cut -d' ' -f1 release/SHA256SUMS)"
+ channel="Nightly prerelease: an automatic build of \`main\` at \`$GITHUB_SHA\`, for trying the newest changes. Nobody chose this commit as a release, it may be broken, and it is never marked Latest. The latest stable release is at $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/latest."
+ version_note="The app reports version $VERSION, the version in Resources/Info.plist at that commit, so the app itself does not tell nightlies apart. The tag \`$TAG\` and the zip's name identify this build."
+ signing="Ad-hoc signed and not notarized, so macOS blocks the first launch until you allow it in System Settings > Privacy & Security. The checksum and attestation below show this zip is what the Release workflow built from that commit of main."
+ install_note="The installer checks the bundle's signature, identifier and version before asking for your password, but it cannot verify where this bundle came from: that is what the checksum and the attestation above are for, and \`--allow-unverified-origin\` says you checked them. It then installs the app, the recovery agent and the sudoers rule described in the README."
+ {
+ echo "Insomnia $VERSION for Apple Silicon Macs with macOS 26 or later. The app is built for arm64 only, so the installer stops on an Intel Mac."
+ echo
+ echo "$channel"
+ echo
+ echo "$version_note"
+ echo
+ echo "$signing"
+ echo
+ echo "## Verify the download"
+ echo
+ echo '```bash'
+ echo "shasum -a 256 -c SHA256SUMS"
+ echo "gh attestation verify $zip -R $GITHUB_REPOSITORY --signer-workflow $GITHUB_REPOSITORY/.github/workflows/release.yml --source-ref $GITHUB_REF --source-digest $GITHUB_SHA"
+ echo '```'
+ echo
+ echo "The second command checks that this zip is the subject of an attestation signed by this repository's Release workflow for \`$GITHUB_REF\` at commit \`$GITHUB_SHA\`, not by just any workflow of this repository, and not for a stable tag."
+ echo
+ echo "SHA-256 of \`$zip\`: \`$sum\`"
+ echo
+ echo "## Install"
+ echo
+ echo '```bash'
+ echo "ditto -x -k $zip ."
+ echo "cd ${zip%.zip}"
+ echo "./install.sh --allow-unverified-origin --app ./Insomnia.app"
+ echo "open \"\$HOME/Applications/Insomnia.app\""
+ echo '```'
+ echo
+ echo "$install_note"
+ echo
+ echo "Built from \`$GITHUB_SHA\` by run $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID."
+ } > notes.md
+ cat notes.md
+
+ - name: Create the tag and the prerelease
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ # Creating a ref fails when it exists, so two runs never share a
+ # tag and an existing one is never moved. A rerun of this job after
+ # a failed release step finds the tag it made on this commit.
+ if ! gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$GITHUB_SHA" --silent; then
+ at="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq '.object.sha')"
+ if [[ "$at" != "$GITHUB_SHA" ]]; then
+ echo "tag $TAG points at $at, not $GITHUB_SHA. Nothing was published." >&2
+ exit 1
+ fi
+ echo "tag $TAG already points at $GITHUB_SHA"
+ fi
+ day="${TAG#nightly-}"
+ day="${day%%-*}"
+ # gh release create fails if the tag already has a release, so a
+ # published nightly's assets are never replaced.
+ gh release create "$TAG" release/*.zip release/SHA256SUMS \
+ --repo "$GITHUB_REPOSITORY" \
+ --title "Insomnia nightly ${day:0:4}-${day:4:2}-${day:6:2} (${GITHUB_SHA:0:12})" \
--notes-file notes.md \
--verify-tag \
- --prerelease
+ --prerelease \
+ --latest=false
diff --git a/README.md b/README.md
index 6436fe0f..79afa14e 100644
--- a/README.md
+++ b/README.md
@@ -40,35 +40,70 @@
Requires **macOS 26 or later on an Apple Silicon Mac**.
+Insomnia is published on two channels on the
+[releases page](https://github.com/krishhgg/Insomnia/releases):
+
+- Stable: the release GitHub marks Latest, tagged `v`. Use this one
+ unless you want to try changes that are not in a stable release yet.
+- Nightly: prereleases tagged `nightly--`, built automatically
+ from `main` once a day when it has new commits. Newest code, experimental,
+ and possibly broken. A nightly is never marked Latest.
+
+Each release's notes give the exact commands for that release. One release
+differs from the steps below: `v0.1.0`, the first stable release, was packaged
+by hand from the maintainer's installed app, not by the Release workflow. It
+has no attestation, and its zip installs with
+`./scripts/install.sh --app ./Insomnia.app`. If the release you picked is
+`v0.1.0` (its zip is `Insomnia-0.1.0-macos-arm64.zip`), follow the install
+steps in its release notes instead of steps 2 and 3.
+
Paste this into your coding agent:
```text
-Install Insomnia from https://github.com/krishhgg/Insomnia by following its README. If a step needs my password, give me the command to run in Terminal.
+Install the latest stable Insomnia release from https://github.com/krishhgg/Insomnia by following its README. If a step needs my password, give me the command to run in Terminal.
```
-Or run it yourself:
+For the newest nightly instead, write "the newest Insomnia nightly
+prerelease" in place of "the latest stable Insomnia release".
+
+Or run it yourself (`gh` is the [GitHub CLI](https://cli.github.com)):
+
+1. Download the zip and `SHA256SUMS` of the release you chose into an empty
+ folder, from the releases page or with `gh`:
+
+ ```bash
+ # Stable: the release marked Latest
+ gh release download --repo krishhgg/Insomnia --pattern '*.zip' --pattern SHA256SUMS
+
+ # Nightly: the newest nightly prerelease
+ tag="$(gh release list --repo krishhgg/Insomnia --exclude-drafts --limit 100 \
+ --json tagName,isPrerelease,publishedAt \
+ --jq '[.[] | select(.isPrerelease and (.tagName | startswith("nightly-")))] | sort_by(.publishedAt) | last | .tagName')"
+ gh release download "$tag" --repo krishhgg/Insomnia --pattern '*.zip' --pattern SHA256SUMS
+ ```
-1. Download `Insomnia--macos.zip` and `SHA256SUMS` from the newest
- release on the [releases page](https://github.com/krishhgg/Insomnia/releases)
- (releases are marked Pre-release). If the page has no release yet, build
- from source (below).
-2. Verify the download (`gh` is the [GitHub CLI](https://cli.github.com)):
+ If the releases page has no release yet, build from source (below).
+2. Verify the download. `` is the zip's file name, and `` is
+ `refs/tags/v` for a stable release or `refs/heads/main` for a
+ nightly:
```bash
shasum -a 256 -c SHA256SUMS
- gh attestation verify Insomnia--macos.zip -R krishhgg/Insomnia \
+ gh attestation verify -R krishhgg/Insomnia \
--signer-workflow krishhgg/Insomnia/.github/workflows/release.yml \
- --source-ref refs/tags/v
+ --source-ref
```
The second command checks that this repository's Release workflow built
- this exact zip for that tag.
+ this exact zip for that ref, so a nightly cannot pass for a stable
+ release. The command in the release notes also adds `--source-digest`
+ with the commit, which pins the zip to that commit.
3. Unzip and run the installer that comes in the zip:
```bash
- ditto -x -k Insomnia--macos.zip .
- cd Insomnia--macos
+ ditto -x -k .
+ cd
./install.sh --allow-unverified-origin --app ./Insomnia.app
open "$HOME/Applications/Insomnia.app"
```
@@ -91,9 +126,9 @@ it is untested there.
### Build from source (experimental)
-Requires **Xcode with Swift 6.2 or later**. Clone the newest release tag
-rather than `main`. While no release exists, leave out `--branch v`
-to build `main`:
+Requires **Xcode with Swift 6.2 or later**. Clone the latest stable tag, or a
+nightly tag for the newest code, rather than `main`. If the releases page has
+no release yet, leave out `--branch v` to build `main`:
```bash
git clone --branch v --depth 1 https://github.com/krishhgg/Insomnia.git
@@ -107,7 +142,8 @@ open "$HOME/Applications/Insomnia.app"
builds only when it runs from a checkout's `scripts` folder, with
`Package.swift` one level up, and then runs the `build-app.sh` beside it. The
`install.sh` from a release zip stops and asks for `--app` instead, even when
-a `build-app.sh` was added to its folder after unpacking.
+a `build-app.sh` was added to its folder after unpacking. A checkout of
+`v0.1.0` has the installer of that release; its README describes it.
[docs/releasing.md](docs/releasing.md) describes the release pipeline.
@@ -596,13 +632,17 @@ From your checkout:
```
From the unpacked release zip, run `./uninstall.sh` (or `./uninstall.sh
---purge`) in the `Insomnia--macos` folder. A checkout's uninstaller
-(in `scripts`, with `Package.swift` one level up) runs the `backstop.sh`
-beside it. Anywhere else, such as the zip's folder, the uninstaller runs only
-the copy sealed in the installed app, after `codesign --verify --strict`
-passes on the app, and stops without removing anything when there is none.
-The zip has no `backstop.sh`, so one added beside its uninstaller is not run.
-Neither looks in the folder above its own.
+--purge`) in its folder (`Insomnia--macos`, or the nightly name). A
+checkout's uninstaller (in `scripts`, with `Package.swift` one level up) runs
+the `backstop.sh` beside it. Anywhere else, such as the zip's folder, the
+uninstaller runs only the copy sealed in the installed app, after `codesign
+--verify --strict` passes on the app, and stops without removing anything
+when there is none. The zip has no `backstop.sh`, so one added beside its
+uninstaller is not run. Neither looks in the folder above its own.
+
+The `v0.1.0` zip is different: run `./scripts/uninstall.sh` in its unpacked
+`Insomnia-0.1.0-macos-arm64` folder, as its release notes say. That older
+uninstaller runs the `scripts/backstop.sh` in the same folder.
The uninstaller requests cleanup before removing the app, agent, and sudoers
rule. If recovery is incomplete or the app refuses to quit, it stops; resolve
diff --git a/SECURITY.md b/SECURITY.md
index 05a004ec..9508a84d 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -59,20 +59,32 @@ tampered bundle; they are not a boundary against a process running as the
same user, which can edit the plist, load its own agent, replace and relaunch
the app, and invoke the four pmset commands directly.
-Release zips are built by the Release workflow from the tagged commit and
-published with a `SHA256SUMS` file and a GitHub build provenance attestation.
-Verify both before installing (`shasum -a 256 -c SHA256SUMS`,
+Release zips on both channels, stable releases (`v` tags) and
+nightly prereleases (`nightly-*` tags built from `main`), are built by the
+Release workflow from the commit they name and published with a
+`SHA256SUMS` file and a GitHub build provenance attestation. Verify both
+before installing (`shasum -a 256 -c SHA256SUMS`,
`gh attestation verify -R krishhgg/Insomnia --signer-workflow
-krishhgg/Insomnia/.github/workflows/release.yml --source-ref
-refs/tags/v`); `install.sh --app` then checks the signature,
-identifier and version of a private copy of the bundle before asking for a
-password, installs that copy, and refuses
+krishhgg/Insomnia/.github/workflows/release.yml --source-ref
+--source-digest `, with `refs/tags/v` or `refs/heads/main`
+as the ref; the release notes carry the command filled in). `install.sh
+--app` then checks the signature, identifier and version of a private copy
+of the bundle before asking for a password, installs that copy, and refuses
it unless `--allow-unverified-origin` is given, because it cannot verify where
a bundle came from, whatever its signature names. The attestation shows which workflow run produced
the bytes, not that the code is free of defects. Releases are ad-hoc signed and
not notarized ([docs/releasing.md](docs/releasing.md)), so macOS blocks their
first launch.
+The first stable release, `v0.1.0`, is the exception. It was packaged by hand
+from the app installed on the maintainer's Mac, so it has no attestation: the
+`SHA256SUMS` file and the per-file hashes in its release notes are the only
+check on the download. Its `scripts/install.sh --app` runs
+`codesign --verify --strict --deep` and checks the bundle identifier before
+the password prompt, but it has no `--allow-unverified-origin` opt-in: it
+installs any intact bundle it is given that has that identifier and the
+0.1.0 layout.
+
Passing automated checks or a secret scan does not establish the absence of
vulnerabilities. Do not probe recovery by disrupting someone else's processes,
power settings, network, or data.
diff --git a/Tests/InsomniaTests/ReleaseWorkflowTests.swift b/Tests/InsomniaTests/ReleaseWorkflowTests.swift
index bfbe4717..bd921544 100644
--- a/Tests/InsomniaTests/ReleaseWorkflowTests.swift
+++ b/Tests/InsomniaTests/ReleaseWorkflowTests.swift
@@ -3,9 +3,11 @@ import XCTest
/// Static checks on .github/workflows/*.yml: every action is pinned to a
/// commit SHA with its version alongside, and no job runs with the default
-/// token permissions. release.yml gets a few more: its triggers, that a
-/// manual run never publishes, and that only the publishing job can write.
-/// Plain text checks; the files are small and have no YAML anchors.
+/// token permissions. release.yml gets a few more: its triggers, that only a
+/// pushed v* tag publishes a stable release and only main a nightly, that the
+/// two channels never share their release flags, and that only the
+/// publishing jobs can write. Plain text checks; the files are small and
+/// have no YAML anchors.
final class ReleaseWorkflowTests: XCTestCase {
private var workflowsDir: URL {
URL(fileURLWithPath: #filePath)
@@ -21,6 +23,59 @@ final class ReleaseWorkflowTests: XCTestCase {
private func lines(_ text: String) -> [String] { text.components(separatedBy: "\n") }
+ private func releaseWorkflow() throws -> String {
+ try XCTUnwrap(try workflows().first { $0.name == "release.yml" }).text
+ }
+
+ /// release.yml's jobs by id, each with its lines up to the next job.
+ private func releaseJobs() throws -> [String: [String]] {
+ let ls = lines(try releaseWorkflow())
+ let start = try XCTUnwrap(ls.firstIndex(of: "jobs:"), "release.yml has no jobs")
+ var jobs: [String: [String]] = [:]
+ var current: String?
+ for line in ls[(start + 1)...] {
+ if line.range(of: #"^ [A-Za-z_][\w-]*:\s*$"#, options: .regularExpression) != nil {
+ let id = String(line.trimmingCharacters(in: .whitespaces).dropLast())
+ XCTAssertNil(jobs[id], "job \(id) appears twice")
+ jobs[id] = []
+ current = id
+ } else if let current {
+ jobs[current, default: []].append(line)
+ }
+ }
+ return jobs
+ }
+
+ private func job(_ id: String) throws -> [String] {
+ try XCTUnwrap(try releaseJobs()[id], "release.yml has no job \(id)")
+ }
+
+ /// The flags of the job's one `gh release create` command (comments
+ /// that name it aside), continuation lines included.
+ private func releaseCreateFlags(_ body: [String]) throws -> [String] {
+ let starts = body.indices.filter { body[$0].trimmingCharacters(in: .whitespaces).hasPrefix("gh release create ") }
+ XCTAssertEqual(starts.count, 1, "one gh release create")
+ var command: [String] = []
+ for line in body[try XCTUnwrap(starts.first)...] {
+ command.append(line)
+ if !line.hasSuffix("\\") { break }
+ }
+ return command.joined(separator: " ").split(separator: " ").map(String.init).filter { $0.hasPrefix("--") }
+ }
+
+ /// The scopes of the job's one `permissions:` block, comments dropped.
+ /// A scope added on any later line, or an inline map or write-all in
+ /// place of the block, fails the caller's comparison.
+ private func permissionScopes(_ body: [String], job id: String) throws -> [String] {
+ let keys = body.indices.filter { body[$0].hasPrefix(" permissions:") }
+ XCTAssertEqual(keys.count, 1, "one permissions key in the \(id) job")
+ let key = try XCTUnwrap(keys.first)
+ XCTAssertEqual(body[key], " permissions:", "\(id): a block, not an inline map or write-all")
+ return body[(key + 1)...]
+ .prefix { $0.hasPrefix(" ") }
+ .map { $0.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0].trimmingCharacters(in: .whitespaces) }
+ }
+
func testEveryActionIsPinnedToACommitSHAWithItsVersionNoted() throws {
let pinned = try NSRegularExpression(pattern: #"^\s*-?\s*uses:\s*[\w.-]+/[\w./-]+@[0-9a-f]{40}\s+#\s*v\d+(\.\d+)*\s*$"#)
for (name, text) in try workflows() {
@@ -65,17 +120,31 @@ final class ReleaseWorkflowTests: XCTestCase {
}
}
- func testReleaseWorkflowRunsOnVersionTagsAndManuallyButOnlyTagsPublish() throws {
- let text = try XCTUnwrap(try workflows().first { $0.name == "release.yml" }).text
+ func testReleaseWorkflowTriggersAndWhatEachOnePublishes() throws {
+ let text = try releaseWorkflow()
XCTAssertTrue(text.contains("tags: ['v*']") || text.contains("tags: [\"v*\"]"), "runs on v* tags")
+ XCTAssertTrue(text.contains("\n schedule:\n") && text.contains("\n - cron: '"), "runs daily for the nightly")
XCTAssertTrue(text.contains("workflow_dispatch:"), "can be run by hand")
+ XCTAssertTrue(text.contains(" inputs:\n nightly:\n description: "), "a manual run can ask for a nightly")
+ XCTAssertTrue(text.contains(" type: boolean\n default: false\n"), "a manual run publishes nothing unless asked")
XCTAssertFalse(text.contains("pull_request"), "never runs for pull requests")
- XCTAssertTrue(text.contains("if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')"), "publishing is gated on a pushed tag, not on a manual run that happens to use a tag ref")
- XCTAssertTrue(text.contains("--signer-workflow $GITHUB_REPOSITORY/.github/workflows/release.yml --source-ref $GITHUB_REF"), "the notes pin the verify command to this workflow and the tag")
- XCTAssertTrue(text.contains(#"echo "./install.sh --allow-unverified-origin --app ./Insomnia.app""#), "the notes' install command carries the origin opt-in install.sh needs for an ad-hoc bundle")
XCTAssertTrue(text.contains("permissions: {}"), "nothing at the top level")
- XCTAssertTrue(text.contains("--prerelease"), "ad-hoc releases are marked prerelease")
- XCTAssertTrue(text.contains("attest-build-provenance"), "build provenance is attested")
+ XCTAssertEqual(Set(try releaseJobs().keys), ["plan", "build", "stable", "nightly"])
+
+ let plan = try job("plan").joined(separator: "\n")
+ XCTAssertTrue(plan.contains(#"if [[ "$NIGHTLY_INPUT" != true ]]; then\#n printf 'channel=artifact\nbuild=true\n' >> "$GITHUB_OUTPUT""#), "a manual run without the input only builds")
+ XCTAssertTrue(plan.contains(#"if [[ "$GITHUB_REF" != refs/heads/main ]]; then"#), "a nightly from any other ref stops before building")
+ XCTAssertTrue(try job("build").contains(" if: needs.plan.outputs.build == 'true'"), "nothing is built when the plan says so")
+
+ let stable = try job("stable")
+ let nightly = try job("nightly")
+ XCTAssertTrue(stable.contains(" if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')"), "a stable release is gated on a pushed tag, not on a manual run that happens to use a tag ref")
+ XCTAssertTrue(nightly.contains(" if: needs.plan.outputs.channel == 'nightly' && github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')"), "a nightly is gated on main and on a scheduled or manual run")
+ for (id, body) in [("stable", stable.joined(separator: "\n")), ("nightly", nightly.joined(separator: "\n"))] {
+ XCTAssertTrue(body.contains("--signer-workflow $GITHUB_REPOSITORY/.github/workflows/release.yml --source-ref $GITHUB_REF --source-digest $GITHUB_SHA"), "\(id): the notes pin the verify command to this workflow, the ref and the commit")
+ XCTAssertTrue(body.contains(#"echo "./install.sh --allow-unverified-origin --app ./Insomnia.app""#), "\(id): the notes' install command carries the origin opt-in install.sh needs for an ad-hoc bundle")
+ XCTAssertTrue(body.contains("attest-build-provenance"), "\(id): build provenance is attested")
+ }
XCTAssertTrue(text.contains("shasum -a 256"), "a checksum file is produced")
XCTAssertTrue(text.contains("ditto -c -k --sequesterRsrc --keepParent"), "zipped the way Finder expects")
XCTAssertTrue(text.contains("CFBundleShortVersionString"), "the tag is checked against the bundle version")
@@ -83,6 +152,30 @@ final class ReleaseWorkflowTests: XCTestCase {
XCTAssertFalse(text.contains("--allow-unsigned"), "no Gatekeeper workarounds")
}
+ /// A stable release is Latest and never a prerelease. A nightly is a
+ /// prerelease, never Latest, under a new tag that names its date and
+ /// commit; the workflow creates that tag with the call that fails when
+ /// it exists, and nothing in the workflow moves, deletes or replaces a
+ /// tag, a release or an asset.
+ func testStableAndNightlyNeverShareTheirReleaseFlags() throws {
+ XCTAssertEqual(try releaseCreateFlags(try job("stable")), ["--repo", "--title", "--notes-file", "--verify-tag", "--latest"])
+ XCTAssertEqual(try releaseCreateFlags(try job("nightly")), ["--repo", "--title", "--notes-file", "--verify-tag", "--prerelease", "--latest=false"])
+ let plan = try job("plan").joined(separator: "\n")
+ XCTAssertTrue(plan.contains(#"sha12="${GITHUB_SHA:0:12}""#))
+ XCTAssertTrue(plan.contains(#"tag="nightly-$(date -u +%Y%m%d)-$sha12""#), "the nightly tag names the UTC date and the commit")
+ XCTAssertTrue(plan.contains(#"matching-refs/tags/nightly-"#), "existing nightly tags are looked up")
+ XCTAssertTrue(plan.contains(#"printf 'channel=nightly\nbuild=false\n' >> "$GITHUB_OUTPUT""#), "a commit that already has a nightly is skipped")
+ let build = try job("build").joined(separator: "\n")
+ XCTAssertTrue(build.contains(#"want="^nightly-[0-9]{8}-${GITHUB_SHA:0:12}\$""#), "the build checks the tag names the commit it built")
+ XCTAssertTrue(build.contains(#"package="Insomnia-$version-$NIGHTLY_TAG-macos""#), "a nightly zip carries its tag in its name")
+ let nightly = try job("nightly").joined(separator: "\n")
+ XCTAssertTrue(nightly.contains(#"gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$GITHUB_SHA""#), "the tag is created on the built commit, failing if it exists")
+ let text = try releaseWorkflow()
+ for absent in ["force", "PATCH", "DELETE", "--clobber", "gh release upload", "gh release edit", "gh release delete", "git push", "git tag"] {
+ XCTAssertFalse(text.contains(absent), "release.yml contains \(absent)")
+ }
+ }
+
/// Releases are ad-hoc signed and not notarized: the workflow reads no
/// secret, imports no certificate, notarizes nothing, build-app.sh has
/// no Developer ID path (no signing identity from the environment, no
@@ -92,7 +185,7 @@ final class ReleaseWorkflowTests: XCTestCase {
/// testInstallFromPrebuiltAppNeedsTheOptInWhateverItsSignatureNames
/// checks the behaviour.
func testTheReleaseIsAdHocSignedWithNoSigningOrNotarizationStep() throws {
- let text = try XCTUnwrap(try workflows().first { $0.name == "release.yml" }).text
+ let text = try releaseWorkflow()
for absent in ["secrets.", "security ", "create-keychain", "notarytool", "stapler", "INSOMNIA_SIGN", "INSOMNIA_NOTARY"] {
XCTAssertFalse(text.contains(absent), "release.yml contains \(absent)")
}
@@ -108,35 +201,22 @@ final class ReleaseWorkflowTests: XCTestCase {
}
}
- /// The build job has read-only access; only the job
- /// that publishes may write, and only what publishing needs.
- func testOnlyTheReleaseJobMayWrite() throws {
- let text = try XCTUnwrap(try workflows().first { $0.name == "release.yml" }).text
- let ls = lines(text)
- let buildStart = try XCTUnwrap(ls.firstIndex(of: " build:"))
- let releaseStart = try XCTUnwrap(ls.firstIndex(of: " release:"))
- XCTAssertLessThan(buildStart, releaseStart)
- let buildLines = Array(ls[buildStart..` for a stable release or `refs/heads/main` for a nightly",
+ "If the release you picked is `v0.1.0`", "Install the latest stable Insomnia release from", "the newest Insomnia nightly prerelease"] {
+ XCTAssertTrue(install.contains(phrase), "the README's Install section lacks: \(phrase)")
+ }
+ }
+
+ private func readmeInstallSection() throws -> String {
let repo = workflowsDir.deletingLastPathComponent().deletingLastPathComponent()
let readme = try String(contentsOf: repo.appendingPathComponent("README.md"), encoding: .utf8)
let install = try XCTUnwrap(readme.range(of: "\n## Install\n"), "README has no Install section")
let end = readme.range(of: "\n## ", range: install.upperBound..-` created by the workflow, prerelease not marked Latest, `gh attestation verify --source-ref refs/heads/main --source-digest ` of the download, and a second run on the same commit skipped before building | Not run |
+| `v0.1.0` zip (hand-packaged from the installed app): `./scripts/install.sh --app ./Insomnia.app` on a working Mac, first launch, and `./scripts/uninstall.sh` from the unpacked folder | Not run |
| Install or uninstall whose own process is killed (`kill -9 `) during a `launchctl bootout` keeps the recovery lock until that bootout has ended or been stopped, about 33 s at most, and the app started afterwards keeps its agent loaded, on a working Mac | Not run |
| First launch over an existing install tightens Application Support/Insomnia and Logs/Insomnia to 0700 and their files to 0600 | Not run |
| Launch at login survives a reinstall by install.sh, including a second install.sh run on the same unchanged build (switch on, reinstall, relaunch: the log shows the launch-time check, System Settings > General > Login Items lists Insomnia as enabled, and the Settings switch reads on; a pending approval shows the note and the Open Login Items button) | Not run |
@@ -281,14 +283,18 @@ working Mac.
## Distribution boundary
Packaging is automated: `scripts/build-app.sh` makes the bundle, and the
-Release workflow tests, packages, checksums, attests and publishes it for a
-`v*` tag (`docs/releasing.md`). PackagingTests run a patched copy of
-`build-app.sh` with the real codesign, RecoveryScriptTests run `install.sh
---app` against prebuilt fixtures, one of them ad-hoc signed by the real
-codesign, and ReleaseWorkflowTests check that every action in the workflows
-is pinned to a commit and that no job has more than read access except the
-one that publishes. No release has been produced with it yet. Releases are
-ad-hoc signed and not notarized, published as prereleases. A consumer
-installation and recovery walkthrough from a downloaded zip has not
-been done. Open-source availability and a passing PR are not equivalent to
-readiness for a public binary release.
+Release workflow tests, packages, checksums, attests and publishes it, as a
+stable release for a `v*` tag and as a nightly prerelease of `main` once a
+day when it has new commits (`docs/releasing.md`). PackagingTests run a
+patched copy of `build-app.sh` with the real codesign, RecoveryScriptTests
+run `install.sh --app` against prebuilt fixtures, one of them ad-hoc signed
+by the real codesign, and ReleaseWorkflowTests check that every action in the
+workflows is pinned to a commit and that no job has more than read access
+except the ones that publish. No release has been produced with it yet.
+Releases are ad-hoc signed and not notarized; stable releases are marked
+Latest, and nightlies are prereleases never marked Latest. The first stable
+release, `v0.1.0`, was packaged by hand from the maintainer's installed app,
+not by the workflow, and has no attestation. A consumer installation and
+recovery walkthrough from a downloaded zip has not been done. Open-source
+availability and a passing PR are not equivalent to readiness for a public
+binary release.
diff --git a/docs/releasing.md b/docs/releasing.md
index 65b912d1..e0482c7f 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -4,13 +4,37 @@ How a downloadable Insomnia build is made, what signs it, and how to verify
one. The Release workflow (`.github/workflows/release.yml`) does the work;
this page is the checklist around it.
+## Channels
+
+| | Stable | Nightly |
+| --- | --- | --- |
+| Made by | pushing a tag `v` | the daily schedule (07:17 UTC), or a manual run with the nightly input, on `main` |
+| Tag | `v`, pushed by the maintainer | `nightly--`, created by the workflow |
+| Zip | `Insomnia--macos.zip` | `Insomnia--nightly---macos.zip` |
+| Title | `Insomnia (stable)` | `Insomnia nightly ()` |
+| GitHub marks | Latest, not a prerelease | Pre-release, never Latest |
+| Attestation source ref | `refs/tags/v` | `refs/heads/main` |
+
+Both channels run the same tests, build, package, checksum and attestation
+steps; they differ only in how the run starts, the names and the release
+flags. Nobody chooses a nightly: it is whatever `main` held when the run
+started.
+
+`v0.1.0` is an exception to this table. It was packaged by hand from the
+app installed on the maintainer's Mac and published as the first stable
+release; no workflow built it. Its zip is `Insomnia-0.1.0-macos-arm64.zip`
+with `scripts/install.sh`, `scripts/uninstall.sh` and `scripts/backstop.sh`
+from the `v0.1.0` tag, it has no attestation, and its release notes give its
+own checksum and install commands. Version 0.1.0 belongs to that release,
+so the next stable release needs a new version in `Resources/Info.plist`.
+
## What a release contains
-`Insomnia--macos.zip` holds one folder with `Insomnia.app`,
-`install.sh` and `uninstall.sh`. `SHA256SUMS` lists the zip's SHA-256. Both
-files are attached to the GitHub Release, and the zip carries a GitHub build
-provenance attestation that names the repository, the tag and the workflow
-run that produced it.
+`Insomnia--macos.zip` (or the nightly name above) holds one folder
+with `Insomnia.app`, `install.sh` and `uninstall.sh`. `SHA256SUMS` lists the
+zip's SHA-256. Both files are attached to the GitHub Release, and the zip
+carries a GitHub build provenance attestation that names the repository, the
+ref, the commit and the workflow run that produced it.
The binary is arm64 only, because the build job runs on an Apple Silicon
runner and no universal build is made. A release therefore runs on Apple
@@ -36,59 +60,102 @@ one, so its `install.sh` needs `--app`, and its `uninstall.sh` runs only the
`backstop.sh` sealed in the installed app, after verifying the app. A
`build-app.sh` or `backstop.sh` added to the unpacked folder is not run.
-## Cutting a release
+## Cutting a stable release
1. Set `CFBundleShortVersionString` in `Resources/Info.plist` to the new
- version (and bump `CFBundleVersion`). Commit on main.
+ version (and bump `CFBundleVersion`). Commit on main. `0.1.0` is taken
+ (see Channels).
2. Tag that commit `v`, the same digits, and push the tag:
```bash
- git tag v0.1.0
- git push origin v0.1.0
+ git tag v0.2.0
+ git push origin v0.2.0
```
The workflow refuses a tag whose digits differ from the plist.
3. Watch the run. It runs `swift test`, builds and ad-hoc signs the bundle,
- packages it, attests the zip and creates the GitHub Release with notes
- that include the checksum and the verify commands.
+ packages it, attests the zip and creates the GitHub Release, marked
+ Latest, with notes that include the checksum and the verify commands.
+ `gh release create` fails if the tag already has a release, so a rerun
+ never replaces published assets.
4. Download the zip from the release and check it the way a user would (see
below), then install it with the command in the release notes
(`./install.sh --allow-unverified-origin --app ./Insomnia.app`) on a Mac
you can afford to reinstall on. Record the result in
`docs/release-validation.md`.
-A manual run (Actions, Release, Run workflow) builds and packages the current
-branch and uploads the zip and `SHA256SUMS` as a workflow artifact. It does
-not attest or publish anything, so it is the way to try the pipeline.
+A manual run (Actions, Release, Run workflow) with the nightly box left
+unticked builds and packages the chosen branch and uploads the zip and
+`SHA256SUMS` as a workflow artifact. It does not attest or publish anything,
+so it is the way to try the pipeline.
+
+## Nightly prereleases
+
+The schedule runs the workflow on `main` once a day. A first job, with
+read-only access, lists the repository's `nightly-*` tags and releases:
+
+- If a nightly tag already names the head of `main` and has a release,
+ nothing is built. A day without new commits publishes nothing.
+- If such a tag exists without a release, the run fails and builds nothing,
+ until `main` moves on. That happens only when an earlier run created the
+ tag and then failed to create the release: rerun that run's failed jobs
+ (the nightly job finds its own tag on the same commit and goes on), or
+ delete the tag.
+- If a nightly tag with this commit's first 12 hex digits points at another
+ commit, the run fails.
+- Otherwise it picks the tag `nightly--` and the run
+ goes on to test, build and package that commit.
+
+The publishing job creates the tag through the API call that fails when the
+ref exists, so it never moves a tag, then creates the release with
+`--prerelease --latest=false`. Scheduled and manual runs on `main` share one
+concurrency group, so two runs never publish the same commit at once.
+
+To publish a nightly now, start a manual run on `main` with the nightly box
+ticked. The same checks apply, and a run on any other branch fails before
+it builds.
+
+GitHub disables scheduled workflows in a public repository after 60 days
+without repository activity; the Release workflow then has to be enabled
+again under Actions. The tags are not protected by a repository rule, so
+someone with write access could still move or delete one by hand.
## Signing
-Releases are ad-hoc signed and not notarized. `build-app.sh` signs with
-`codesign --sign -`, so the workflow uses no secrets and no keychain, and the
-signature identifies only that build (its cdhash). The release is marked a
-prerelease, and macOS blocks the first launch of the downloaded app until the
-user allows it in System Settings > Privacy & Security.
+Releases on both channels are ad-hoc signed and not notarized.
+`build-app.sh` signs with `codesign --sign -`, so the workflow uses no
+secrets and no keychain, and the signature identifies only that build (its
+cdhash). The stable label is about the channel, not the signature: macOS
+blocks the first launch of any downloaded release until the user allows it
+in System Settings > Privacy & Security.
## Verifying a download
```bash
shasum -a 256 -c SHA256SUMS
-gh attestation verify Insomnia--macos.zip -R krishhgg/Insomnia \
+gh attestation verify -R krishhgg/Insomnia \
--signer-workflow krishhgg/Insomnia/.github/workflows/release.yml \
- --source-ref refs/tags/v
+ --source-ref --source-digest
```
+`` is `refs/tags/v` for a stable release and `refs/heads/main`
+for a nightly; `` is the full commit the release was built from. The
+release notes carry the command filled in.
+
The first line checks the zip against the checksum published with it. The
second asks GitHub for the attestation signed when the workflow ran and
checks that this zip is its subject, that the signing workflow is this
repository's `release.yml` (`--signer-workflow`; with `-R` alone any workflow
-of the repository would do) and that it ran for the tag (`--source-ref`).
-Together they show the bytes are what the Release workflow built from the
-tagged commit. They do not show the code is safe; the README's warnings
-apply to every build.
-
-`install.sh --app` then copies the bundle into a private temporary
-directory and checks that copy before the password prompt: it runs
+of the repository would do), that it ran for that ref (`--source-ref`), so a
+nightly built from `main` does not pass for a stable tag, and that it built
+that commit (`--source-digest`). Together they show the bytes are what the
+Release workflow built from that commit. They do not show the code is safe;
+the README's warnings apply to every build. `v0.1.0` has no attestation; only
+the checksum in its release notes covers it.
+
+The `install.sh --app` in a workflow-built zip then copies the bundle into
+a private temporary directory and checks that copy before the password
+prompt: it runs
`codesign --verify --strict --deep` and checks the bundle identifier and
version. It installs that copy, not the path it was given, so a bundle
replaced at that path while the prompt waits is never installed. The copy it
@@ -97,16 +164,18 @@ recovery agent pins.
What is verified: the checksum shows the zip was not altered after
`SHA256SUMS` was written; the attestation shows this repository's Release
-workflow built this exact zip from the tag; `install.sh --app` shows the
-bundle inside is intact (signature and resource seal), has the expected
-identifier and a version, and carries the sealed backstop. What is not:
-`install.sh` cannot tell a bundle from this repository apart from one anyone
-else signed with the same identifier, and it does not take any signature,
-ad-hoc or not, as proof of where a bundle came from. So it refuses to install
-without `--allow-unverified-origin`, the flag that says you ran the two
-commands above yourself. The release notes carry that flag in the install command.
+workflow built this exact zip from that ref and commit; `install.sh --app`
+shows the bundle inside is intact (signature and resource seal), has the
+expected identifier and a version, and carries the sealed backstop. What is
+not: `install.sh` cannot tell a bundle from this repository apart from one
+anyone else signed with the same identifier, and it does not take any
+signature, ad-hoc or not, as proof of where a bundle came from. So it
+refuses to install without `--allow-unverified-origin`, the flag that says
+you ran the two commands above yourself. The release notes carry that flag
+in the install command.
## What is not automated
The workflow does not install anything anywhere. The release validation
-record is still written by hand after a real install.
+record is still written by hand after a real install. Nothing checks a
+nightly beyond the workflow's `swift test`.
From 137a9d811456c0e3ce6b45fd0509e52b59e405bf Mon Sep 17 00:00:00 2001
From: Krish Garg
Date: Sat, 10 Oct 2026 09:58:48 -0700
Subject: [PATCH 02/10] Release docs: nightly download never falls back to
Latest, verify pins the commit
The README's nightly download is now one function. It reads every page of
the release list with gh api --paginate and downloads only when the newest
published nightly prerelease has a tag of the form nightly-<8 digits>-<12
hex digits>. A failed list, no nightly yet, or any other tag stops it with
a message, a nonzero status and nothing downloaded. Before, an empty or
failed list left the tag empty, and gh release download "" fetched the
Latest stable release instead.
Step 2's attestation command now carries --source-digest , the full
commit the release notes name, since every nightly shares refs/heads/main
and the ref alone lets another nightly's zip pass.
README and SECURITY.md scope the installer's version check, the backstop
sealed in the app and the code requirement the recovery agent pins to
workflow-built releases and builds of current source. v0.1.0's installer
checks no version; its agent runs a backstop.sh copied into Application
Support with /bin/bash and checks no signature. Readers are sent to the
README in its zip.
ReleaseWorkflowTests runs the plan job's script from release.yml under
bash -e with a stub gh: a published nightly of the commit is skipped, a
tag without a release or naming another commit stops the run with no
outputs, and a commit with no nightly gets a new tag. The README check now
requires --source-digest .
README also gains a Star history section with the star-history.com chart
(light and dark).
Co-Authored-By: Claude Opus 5.5 (1M context)
---
README.md | 86 +++++++++++---
SECURITY.md | 15 ++-
.../InsomniaTests/ReleaseWorkflowTests.swift | 109 +++++++++++++++++-
3 files changed, 189 insertions(+), 21 deletions(-)
diff --git a/README.md b/README.md
index 79afa14e..7f523878 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,13 @@ has no attestation, and its zip installs with
`v0.1.0` (its zip is `Insomnia-0.1.0-macos-arm64.zip`), follow the install
steps in its release notes instead of steps 2 and 3.
+The rest of this README describes releases built by the Release workflow and
+builds of the current source, not `v0.1.0`. The `README.md` in the `v0.1.0`
+zip describes what that release installs, how its recovery works and its
+limits. In particular, the installer's version check, the recovery script
+sealed inside the app and the code requirement the recovery agent pins,
+described below, are not in `v0.1.0`.
+
Paste this into your coding agent:
```text
@@ -69,35 +76,63 @@ prerelease" in place of "the latest stable Insomnia release".
Or run it yourself (`gh` is the [GitHub CLI](https://cli.github.com)):
1. Download the zip and `SHA256SUMS` of the release you chose into an empty
- folder, from the releases page or with `gh`:
+ folder, from the releases page or with `gh`. For the stable release, the
+ one marked Latest:
```bash
- # Stable: the release marked Latest
gh release download --repo krishhgg/Insomnia --pattern '*.zip' --pattern SHA256SUMS
+ ```
- # Nightly: the newest nightly prerelease
- tag="$(gh release list --repo krishhgg/Insomnia --exclude-drafts --limit 100 \
- --json tagName,isPrerelease,publishedAt \
- --jq '[.[] | select(.isPrerelease and (.tagName | startswith("nightly-")))] | sort_by(.publishedAt) | last | .tagName')"
- gh release download "$tag" --repo krishhgg/Insomnia --pattern '*.zip' --pattern SHA256SUMS
+ For the newest nightly, paste this whole block. It reads every page of
+ the release list and picks the most recently published nightly. It
+ downloads nothing, and returns an error, when it cannot read the list,
+ when no nightly has been published yet, or when the tag it picked is not
+ in the `nightly--<12-character commit>` form. It never falls
+ back to the stable release.
+
+ ```bash
+ insomnia_nightly() {
+ local list tag
+ list="$(gh api --paginate 'repos/krishhgg/Insomnia/releases?per_page=100' \
+ --jq '.[] | select(.prerelease and (.draft | not) and (.tag_name | startswith("nightly-")))
+ | "\(.published_at) \(.tag_name)"')" ||
+ { echo "Could not read the release list. Nothing was downloaded." >&2; return 1; }
+ tag="$(printf '%s\n' "$list" | sort | tail -n 1)"
+ tag="${tag#* }"
+ if [ -z "$tag" ]; then
+ echo "No nightly has been published yet. Nothing was downloaded." >&2
+ echo "Wait for the next daily build, or build from source." >&2
+ return 1
+ fi
+ if ! printf '%s\n' "$tag" | grep -Eqx 'nightly-[0-9]{8}-[0-9a-f]{12}'; then
+ printf 'Unexpected nightly tag: %s. Nothing was downloaded.\n' "$tag" >&2
+ return 1
+ fi
+ gh release download "$tag" --repo krishhgg/Insomnia --pattern '*.zip' --pattern SHA256SUMS
+ }
+ insomnia_nightly
```
If the releases page has no release yet, build from source (below).
-2. Verify the download. `` is the zip's file name, and `` is
+2. Verify the download. `` is the zip's file name. `` is
`refs/tags/v` for a stable release or `refs/heads/main` for a
- nightly:
+ nightly. `` is the full 40-character commit the release was built
+ from: its release notes name it after "Built from" and carry this command
+ filled in. A nightly's tag ends with the first 12 characters of it.
```bash
shasum -a 256 -c SHA256SUMS
gh attestation verify -R krishhgg/Insomnia \
--signer-workflow krishhgg/Insomnia/.github/workflows/release.yml \
- --source-ref
+ --source-ref --source-digest
```
- The second command checks that this repository's Release workflow built
- this exact zip for that ref, so a nightly cannot pass for a stable
- release. The command in the release notes also adds `--source-digest`
- with the commit, which pins the zip to that commit.
+ The first command checks the zip against the `SHA256SUMS` downloaded with
+ it. The second checks that this repository's Release workflow built this
+ exact zip from that commit for that ref, so a nightly cannot pass for a
+ stable release. Every nightly is built for `refs/heads/main`, so only
+ `--source-digest` keeps another nightly's zip from passing: do not leave
+ it out, and do not shorten the commit to the 12 characters in the tag.
3. Unzip and run the installer that comes in the zip:
@@ -115,7 +150,8 @@ Or run it yourself (`gh` is the [GitHub CLI](https://cli.github.com)):
System Settings > Privacy & Security.
The installer checks the bundle's signature, identifier and version before it
-asks for anything. It then installs the app and a background recovery agent,
+asks for anything (the `v0.1.0` installer checks the signature and
+identifier, not the version). It then installs the app and a background recovery agent,
and asks for administrator access to install a narrowly scoped sudoers rule. It grants
**your user account**, not just Insomnia, passwordless access to four
power-setting commands. Review that permission before installing.
@@ -149,6 +185,16 @@ a `build-app.sh` was added to its folder after unpacking. A checkout of
Exactly what gets installed
+This describes releases built by the Release workflow and builds of the
+current source. `v0.1.0` installs an older layout. Its app has no
+`backstop.sh` inside it: its installer copies `backstop.sh` into
+`~/Library/Application Support/Insomnia/`, and its recovery agent runs that
+copy with `/bin/bash` without checking any code signature. The sealed script,
+the pinned code requirement and the refusal of an edited or re-signed bundle
+described below do not apply to it, and neither does the upgrade procedure.
+Its sudoers rule grants the same four commands. The `README.md` in its zip
+describes what it installs and its limits.
+
| Location | Purpose |
| --- | --- |
| `~/Applications/Insomnia.app` | The menu bar app, with `backstop.sh` sealed inside it at `Contents/Resources` |
@@ -675,6 +721,16 @@ offline with Xcode's swiftc and iconutil.
[Contributing](CONTRIBUTING.md) · [Security reporting](SECURITY.md) ·
[Release validation](docs/release-validation.md) · [Design notes](docs/spec.md)
+## Star history
+
+
+
+
+
+
+
+
+
## License
[MIT](LICENSE)
diff --git a/SECURITY.md b/SECURITY.md
index 9508a84d..3bcbf397 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -45,8 +45,9 @@ ignores the data-protection accessibility classes (this device only, when
unlocked); using that Keychain needs an access-group entitlement, which needs
a team id. Releases are ad-hoc signed, so both limits stay.
-The recovery LaunchAgent runs only the `backstop.sh` sealed inside the signed
-app bundle, after `codesign --verify --strict` passes against the code
+In releases built by the Release workflow and in builds of the current
+source, the recovery LaunchAgent runs only the `backstop.sh` sealed inside the
+signed app bundle, after `codesign --verify --strict` passes against the code
requirement pinned in its plist (the build's cdhash for an ad-hoc signature).
An edited bundle or script is refused and logged. The app pins the code it is
running (its own designated requirement, after confirming that the bundle on
@@ -67,7 +68,8 @@ before installing (`shasum -a 256 -c SHA256SUMS`,
`gh attestation verify -R krishhgg/Insomnia --signer-workflow
krishhgg/Insomnia/.github/workflows/release.yml --source-ref
--source-digest `, with `refs/tags/v` or `refs/heads/main`
-as the ref; the release notes carry the command filled in). `install.sh
+as the ref and the full commit the release was built from as the digest; the
+release notes carry the command filled in). `install.sh
--app` then checks the signature, identifier and version of a private copy
of the bundle before asking for a password, installs that copy, and refuses
it unless `--allow-unverified-origin` is given, because it cannot verify where
@@ -83,7 +85,12 @@ check on the download. Its `scripts/install.sh --app` runs
`codesign --verify --strict --deep` and checks the bundle identifier before
the password prompt, but it has no `--allow-unverified-origin` opt-in: it
installs any intact bundle it is given that has that identifier and the
-0.1.0 layout.
+0.1.0 layout. It does not check the version. Its recovery agent predates the
+sealed script and the pinned requirement described above: the installer
+copies `backstop.sh` into `~/Library/Application Support/Insomnia/`, and the
+agent runs that copy with `/bin/bash` without checking any code signature, so
+an edited copy runs as it is. The `README.md` in its zip describes that
+layout and its limits.
Passing automated checks or a secret scan does not establish the absence of
vulnerabilities. Do not probe recovery by disrupting someone else's processes,
diff --git a/Tests/InsomniaTests/ReleaseWorkflowTests.swift b/Tests/InsomniaTests/ReleaseWorkflowTests.swift
index bd921544..65bfbe49 100644
--- a/Tests/InsomniaTests/ReleaseWorkflowTests.swift
+++ b/Tests/InsomniaTests/ReleaseWorkflowTests.swift
@@ -1,13 +1,15 @@
import Foundation
import XCTest
+@testable import Insomnia
/// Static checks on .github/workflows/*.yml: every action is pinned to a
/// commit SHA with its version alongside, and no job runs with the default
/// token permissions. release.yml gets a few more: its triggers, that only a
/// pushed v* tag publishes a stable release and only main a nightly, that the
/// two channels never share their release flags, and that only the
-/// publishing jobs can write. Plain text checks; the files are small and
-/// have no YAML anchors.
+/// publishing jobs can write. Plain text checks, except that the plan job's
+/// script also runs with a stub `gh`; the files are small and have no YAML
+/// anchors.
final class ReleaseWorkflowTests: XCTestCase {
private var workflowsDir: URL {
URL(fileURLWithPath: #filePath)
@@ -176,6 +178,108 @@ final class ReleaseWorkflowTests: XCTestCase {
}
}
+ /// Runs the plan job's script from release.yml, as a scheduled run on
+ /// main would, with a stub `gh` serving the nightly tags and the release
+ /// names of each case. Only a commit whose nightly tag has a release is
+ /// skipped. A tag with no release, or a tag naming this commit's first 12
+ /// characters on another commit, stops the run with no outputs, so
+ /// nothing is built. A commit with no nightly tag gets a new one.
+ func testThePlanSkipsOnlyACommitWhoseNightlyHasARelease() throws {
+ let script = try planScript()
+ let sha = "0123456789abcdef0123456789abcdef01234567"
+ let tag = "nightly-20261009-0123456789ab"
+ let olderRef = "refs/tags/nightly-20261008-fedcba987654 fedcba9876543210fedcba9876543210fedcba98"
+ let olderRelease = "nightly-20261008-fedcba987654"
+
+ var r = try runPlan(script, sha: sha, refs: [olderRef, "refs/tags/\(tag) \(sha)"], releases: ["v0.1.0", olderRelease, tag])
+ XCTAssertEqual(r.status, 0, r.stderr)
+ XCTAssertEqual(r.output, "channel=nightly\nbuild=false\n", "a published nightly of this commit is skipped")
+
+ // A release whose name only starts with the tag is not its release.
+ r = try runPlan(script, sha: sha, refs: [olderRef, "refs/tags/\(tag) \(sha)"], releases: ["v0.1.0", olderRelease, "\(tag)-rc"])
+ XCTAssertEqual(r.status, 1, "a tag without a release stops the run")
+ XCTAssertEqual(r.output, "", "no outputs, so nothing is built")
+ XCTAssertTrue(r.stderr.contains("tag \(tag) exists but has no release"), r.stderr)
+
+ let foreign = "0123456789ab" + String(repeating: "f", count: 28)
+ r = try runPlan(script, sha: sha, refs: ["refs/tags/\(tag) \(foreign)"], releases: [tag])
+ XCTAssertEqual(r.status, 1, "a tag of another commit with the same first 12 characters stops the run")
+ XCTAssertEqual(r.output, "", "no outputs, so nothing is built")
+ XCTAssertTrue(r.stderr.contains("tag \(tag) points at \(foreign), not \(sha)"), r.stderr)
+
+ // A tag whose name only starts with this commit's nightly name is not its nightly.
+ r = try runPlan(script, sha: sha, refs: [olderRef, "refs/tags/\(tag)-x \(sha)"], releases: ["v0.1.0", olderRelease])
+ XCTAssertEqual(r.status, 0, r.stderr)
+ XCTAssertNotNil(r.output.range(of: #"^channel=nightly\nbuild=true\ntag=nightly-[0-9]{8}-0123456789ab\n$"#, options: .regularExpression),
+ "a commit with no nightly gets a new tag: \(r.output)")
+ }
+
+ /// The plan job's one `run: |` block, unindented.
+ private func planScript() throws -> String {
+ let body = try job("plan")
+ let runs = body.indices.filter { body[$0] == " run: |" }
+ XCTAssertEqual(runs.count, 1, "one run block in the plan job")
+ let script = body[(try XCTUnwrap(runs.first) + 1)...]
+ .prefix { $0.isEmpty || $0.hasPrefix(" ") }
+ .map { String($0.dropFirst(10)) }
+ XCTAssertTrue(script.contains("done <<< \"$refs\""), "the whole block, up to the end of the tag loop")
+ return script.joined(separator: "\n") + "\n"
+ }
+
+ /// Runs the plan script under `bash -e`, as the runner does, with only
+ /// the stub `gh` ahead of /usr/bin:/bin. The stub prints the given tag
+ /// refs and release names, what the script's two `gh api` calls would
+ /// print after their jq filters, and fails any other call.
+ private func runPlan(_ script: String, sha: String, refs: [String], releases: [String]) throws -> (status: Int32, output: String, stderr: String) {
+ let fm = FileManager.default
+ let root = fm.temporaryDirectory.appendingPathComponent("release-plan-\(UUID().uuidString)", isDirectory: true)
+ let bin = root.appendingPathComponent("bin", isDirectory: true)
+ try fm.createDirectory(at: bin, withIntermediateDirectories: true)
+ addTeardownBlock { try? FileManager.default.removeItem(at: root) }
+ try (refs.map { $0 + "\n" }.joined()).write(to: root.appendingPathComponent("refs"), atomically: true, encoding: .utf8)
+ try (releases.map { $0 + "\n" }.joined()).write(to: root.appendingPathComponent("releases"), atomically: true, encoding: .utf8)
+ let gh = bin.appendingPathComponent("gh")
+ try #"""
+ #!/bin/bash
+ here="$(cd "$(dirname "$0")/.." && pwd)"
+ if [[ $# -eq 5 && $1 == api && $2 == --paginate && $4 == --jq ]]; then
+ case "$3" in
+ repos/krishhgg/Insomnia/git/matching-refs/tags/nightly-) exec /bin/cat "$here/refs" ;;
+ "repos/krishhgg/Insomnia/releases?per_page=100") exec /bin/cat "$here/releases" ;;
+ esac
+ fi
+ echo "stub gh: unexpected call: $*" >&2
+ exit 99
+
+ """#.write(to: gh, atomically: true, encoding: .utf8)
+ try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: gh.path)
+ let scriptURL = root.appendingPathComponent("plan.sh")
+ try script.write(to: scriptURL, atomically: true, encoding: .utf8)
+ let outputURL = root.appendingPathComponent("output")
+ let errURL = root.appendingPathComponent("stderr")
+ for url in [outputURL, errURL] { fm.createFile(atPath: url.path, contents: nil) }
+
+ let p = Process()
+ p.executableURL = URL(fileURLWithPath: "/bin/bash")
+ p.arguments = ["-e", scriptURL.path]
+ p.environment = [
+ "PATH": "\(bin.path):/usr/bin:/bin", "TMPDIR": NSTemporaryDirectory(),
+ "EVENT": "schedule", "NIGHTLY_INPUT": "", "GITHUB_REF": "refs/heads/main", "GITHUB_SHA": sha,
+ "GITHUB_REPOSITORY": "krishhgg/Insomnia", "GITHUB_OUTPUT": outputURL.path,
+ ]
+ // Capture to files rather than pipes: nothing to drain, nothing to deadlock.
+ let err = try FileHandle(forWritingTo: errURL)
+ defer { try? err.close() }
+ p.standardOutput = FileHandle.nullDevice
+ p.standardError = err
+ let childExit = ProcessExit(p)
+ try p.run()
+ childExit.wait()
+ return (p.terminationStatus,
+ (try? String(contentsOf: outputURL, encoding: .utf8)) ?? "",
+ (try? String(contentsOf: errURL, encoding: .utf8)) ?? "")
+ }
+
/// Releases are ad-hoc signed and not notarized: the workflow reads no
/// secret, imports no certificate, notarizes nothing, build-app.sh has
/// no Developer ID path (no signing identity from the environment, no
@@ -237,6 +341,7 @@ final class ReleaseWorkflowTests: XCTestCase {
let install = try readmeInstallSection().split(whereSeparator: \.isWhitespace).joined(separator: " ")
for phrase in ["Stable: the release GitHub marks Latest", "Nightly: prereleases tagged `nightly-", "A nightly is never marked Latest",
"`refs/tags/v` for a stable release or `refs/heads/main` for a nightly",
+ "--source-ref --source-digest ",
"If the release you picked is `v0.1.0`", "Install the latest stable Insomnia release from", "the newest Insomnia nightly prerelease"] {
XCTAssertTrue(install.contains(phrase), "the README's Install section lacks: \(phrase)")
}
From 6f388a12c96a0b55b1aa7946674f661d1ca2d3b6 Mon Sep 17 00:00:00 2001
From: Krish Garg
Date: Sat, 10 Oct 2026 10:02:50 -0700
Subject: [PATCH 03/10] Release tests: run the nightly tag step as well as the
plan
ReleaseWorkflowTests now also runs the nightly job's "Create the tag and
the prerelease" step from release.yml under bash -e with a stub gh. A new
tag is created on the built commit and the prerelease follows. When the
tag already exists, as on a rerun of the job, the step reads it and goes
on only if it names the built commit. A tag on another commit, or one that
cannot be read, stops the step before gh release create runs.
The plan test and this one share one runner that extracts a step's run
block by name and records the stub's calls.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../InsomniaTests/ReleaseWorkflowTests.swift | 150 +++++++++++++-----
1 file changed, 112 insertions(+), 38 deletions(-)
diff --git a/Tests/InsomniaTests/ReleaseWorkflowTests.swift b/Tests/InsomniaTests/ReleaseWorkflowTests.swift
index 65bfbe49..b9c5a78f 100644
--- a/Tests/InsomniaTests/ReleaseWorkflowTests.swift
+++ b/Tests/InsomniaTests/ReleaseWorkflowTests.swift
@@ -8,8 +8,8 @@ import XCTest
/// pushed v* tag publishes a stable release and only main a nightly, that the
/// two channels never share their release flags, and that only the
/// publishing jobs can write. Plain text checks, except that the plan job's
-/// script also runs with a stub `gh`; the files are small and have no YAML
-/// anchors.
+/// script and the nightly job's tag step also run with a stub `gh`; the
+/// files are small and have no YAML anchors.
final class ReleaseWorkflowTests: XCTestCase {
private var workflowsDir: URL {
URL(fileURLWithPath: #filePath)
@@ -185,88 +185,162 @@ final class ReleaseWorkflowTests: XCTestCase {
/// characters on another commit, stops the run with no outputs, so
/// nothing is built. A commit with no nightly tag gets a new one.
func testThePlanSkipsOnlyACommitWhoseNightlyHasARelease() throws {
- let script = try planScript()
+ let script = try stepScript("plan", "Choose the channel, and the tag for a nightly")
let sha = "0123456789abcdef0123456789abcdef01234567"
let tag = "nightly-20261009-0123456789ab"
let olderRef = "refs/tags/nightly-20261008-fedcba987654 fedcba9876543210fedcba9876543210fedcba98"
let olderRelease = "nightly-20261008-fedcba987654"
+ // The stub prints what the script's two `gh api` calls would print
+ // after their jq filters, and fails any other call.
+ let stub = """
+ if [[ $# -eq 5 && $1 == api && $2 == --paginate && $4 == --jq ]]; then
+ case "$3" in
+ repos/krishhgg/Insomnia/git/matching-refs/tags/nightly-) exec /bin/cat "$here/refs" ;;
+ "repos/krishhgg/Insomnia/releases?per_page=100") exec /bin/cat "$here/releases" ;;
+ esac
+ fi
+ """
+ func plan(refs: [String], releases: [String]) throws -> StepResult {
+ try runStep(script, gh: stub,
+ files: ["refs": refs.map { $0 + "\n" }.joined(), "releases": releases.map { $0 + "\n" }.joined()],
+ environment: ["EVENT": "schedule", "NIGHTLY_INPUT": "", "GITHUB_REF": "refs/heads/main", "GITHUB_SHA": sha])
+ }
- var r = try runPlan(script, sha: sha, refs: [olderRef, "refs/tags/\(tag) \(sha)"], releases: ["v0.1.0", olderRelease, tag])
+ var r = try plan(refs: [olderRef, "refs/tags/\(tag) \(sha)"], releases: ["v0.1.0", olderRelease, tag])
XCTAssertEqual(r.status, 0, r.stderr)
XCTAssertEqual(r.output, "channel=nightly\nbuild=false\n", "a published nightly of this commit is skipped")
// A release whose name only starts with the tag is not its release.
- r = try runPlan(script, sha: sha, refs: [olderRef, "refs/tags/\(tag) \(sha)"], releases: ["v0.1.0", olderRelease, "\(tag)-rc"])
+ r = try plan(refs: [olderRef, "refs/tags/\(tag) \(sha)"], releases: ["v0.1.0", olderRelease, "\(tag)-rc"])
XCTAssertEqual(r.status, 1, "a tag without a release stops the run")
XCTAssertEqual(r.output, "", "no outputs, so nothing is built")
XCTAssertTrue(r.stderr.contains("tag \(tag) exists but has no release"), r.stderr)
let foreign = "0123456789ab" + String(repeating: "f", count: 28)
- r = try runPlan(script, sha: sha, refs: ["refs/tags/\(tag) \(foreign)"], releases: [tag])
+ r = try plan(refs: ["refs/tags/\(tag) \(foreign)"], releases: [tag])
XCTAssertEqual(r.status, 1, "a tag of another commit with the same first 12 characters stops the run")
XCTAssertEqual(r.output, "", "no outputs, so nothing is built")
XCTAssertTrue(r.stderr.contains("tag \(tag) points at \(foreign), not \(sha)"), r.stderr)
// A tag whose name only starts with this commit's nightly name is not its nightly.
- r = try runPlan(script, sha: sha, refs: [olderRef, "refs/tags/\(tag)-x \(sha)"], releases: ["v0.1.0", olderRelease])
+ r = try plan(refs: [olderRef, "refs/tags/\(tag)-x \(sha)"], releases: ["v0.1.0", olderRelease])
XCTAssertEqual(r.status, 0, r.stderr)
XCTAssertNotNil(r.output.range(of: #"^channel=nightly\nbuild=true\ntag=nightly-[0-9]{8}-0123456789ab\n$"#, options: .regularExpression),
"a commit with no nightly gets a new tag: \(r.output)")
}
- /// The plan job's one `run: |` block, unindented.
- private func planScript() throws -> String {
- let body = try job("plan")
- let runs = body.indices.filter { body[$0] == " run: |" }
- XCTAssertEqual(runs.count, 1, "one run block in the plan job")
- let script = body[(try XCTUnwrap(runs.first) + 1)...]
+ /// Runs the nightly job's last step with a stub `gh`. The tag is created
+ /// on the built commit; when it already exists, as on a rerun of this
+ /// job, it is used only if it names that commit. A tag on another commit,
+ /// or one that cannot be read, stops the job before any release exists.
+ func testTheNightlyTagIsCreatedOrReusedOnlyOnItsOwnCommit() throws {
+ let script = try stepScript("nightly", "Create the tag and the prerelease")
+ let sha = "0123456789abcdef0123456789abcdef01234567"
+ let tag = "nightly-20261009-0123456789ab"
+ let post = "api repos/krishhgg/Insomnia/git/refs -f ref=refs/tags/\(tag) -f sha=\(sha) --silent"
+ let get = "api repos/krishhgg/Insomnia/git/ref/tags/\(tag) --jq .object.sha"
+ let create = "release create \(tag) release/Insomnia-0.1.0-\(tag)-macos.zip release/SHA256SUMS --repo krishhgg/Insomnia"
+ + " --title Insomnia nightly 2026-10-09 (0123456789ab) --notes-file notes.md --verify-tag --prerelease --latest=false"
+ // Creating the ref exits with the status in create-status; reading it
+ // prints the file `at`, or fails as GitHub does when there is none.
+ let stub = """
+ case "$1 $2" in
+ "api repos/krishhgg/Insomnia/git/refs") exit "$(/bin/cat "$here/create-status")" ;;
+ "api repos/krishhgg/Insomnia/git/ref/tags/\(tag)")
+ [[ -e $here/at ]] || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; }
+ exec /bin/cat "$here/at" ;;
+ "release create") exit 0 ;;
+ esac
+ """
+ func publish(createStatus: Int, at: String?) throws -> StepResult {
+ var files = ["release/Insomnia-0.1.0-\(tag)-macos.zip": "", "release/SHA256SUMS": "", "notes.md": "notes\n",
+ "create-status": "\(createStatus)\n"]
+ if let at { files["at"] = at + "\n" }
+ return try runStep(script, gh: stub, files: files, environment: ["GITHUB_SHA": sha, "TAG": tag])
+ }
+
+ var r = try publish(createStatus: 0, at: nil)
+ XCTAssertEqual(r.status, 0, r.stderr)
+ XCTAssertEqual(r.calls, [post, create], "a new tag on the built commit, then the prerelease")
+
+ r = try publish(createStatus: 1, at: sha)
+ XCTAssertEqual(r.status, 0, r.stderr)
+ XCTAssertEqual(r.calls, [post, get, create], "a rerun uses the tag it made on this commit")
+
+ let foreign = "0123456789ab" + String(repeating: "f", count: 28)
+ r = try publish(createStatus: 1, at: foreign)
+ XCTAssertEqual(r.status, 1)
+ XCTAssertEqual(r.calls, [post, get], "a tag on another commit stops the job before any release")
+ XCTAssertTrue(r.stderr.contains("tag \(tag) points at \(foreign), not \(sha)"), r.stderr)
+
+ r = try publish(createStatus: 1, at: nil)
+ XCTAssertNotEqual(r.status, 0)
+ XCTAssertEqual(r.calls, [post, get], "a tag that cannot be read stops the job before any release")
+ }
+
+ /// The `run: |` block of the job's step with this name, unindented.
+ private func stepScript(_ id: String, _ step: String) throws -> String {
+ let body = try job(id)
+ let names = body.indices.filter { body[$0] == " - name: \(step)" }
+ XCTAssertEqual(names.count, 1, "one \(step) step in the \(id) job")
+ let name = try XCTUnwrap(names.first)
+ let run = try XCTUnwrap(body[name...].firstIndex(of: " run: |"), "\(step) has no run block")
+ XCTAssertFalse(body[(name + 1).. (status: Int32, output: String, stderr: String) {
+ private struct StepResult {
+ var status: Int32
+ var output: String
+ var stderr: String
+ var calls: [String]
+ }
+
+ /// Runs a step's script under `bash -e`, as the runner does, in a fresh
+ /// directory holding `files`, with only a stub `gh` ahead of
+ /// /usr/bin:/bin. The stub logs each call's arguments, runs `body` with
+ /// `$here` set to that directory, and fails any call `body` does not
+ /// end. Returns what the step wrote to $GITHUB_OUTPUT and the calls.
+ private func runStep(_ script: String, gh body: String, files: [String: String], environment: [String: String]) throws -> StepResult {
let fm = FileManager.default
- let root = fm.temporaryDirectory.appendingPathComponent("release-plan-\(UUID().uuidString)", isDirectory: true)
+ let root = fm.temporaryDirectory.appendingPathComponent("release-step-\(UUID().uuidString)", isDirectory: true)
let bin = root.appendingPathComponent("bin", isDirectory: true)
try fm.createDirectory(at: bin, withIntermediateDirectories: true)
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
- try (refs.map { $0 + "\n" }.joined()).write(to: root.appendingPathComponent("refs"), atomically: true, encoding: .utf8)
- try (releases.map { $0 + "\n" }.joined()).write(to: root.appendingPathComponent("releases"), atomically: true, encoding: .utf8)
+ for (path, contents) in files {
+ let url = root.appendingPathComponent(path)
+ try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try contents.write(to: url, atomically: true, encoding: .utf8)
+ }
let gh = bin.appendingPathComponent("gh")
- try #"""
+ try """
#!/bin/bash
here="$(cd "$(dirname "$0")/.." && pwd)"
- if [[ $# -eq 5 && $1 == api && $2 == --paginate && $4 == --jq ]]; then
- case "$3" in
- repos/krishhgg/Insomnia/git/matching-refs/tags/nightly-) exec /bin/cat "$here/refs" ;;
- "repos/krishhgg/Insomnia/releases?per_page=100") exec /bin/cat "$here/releases" ;;
- esac
- fi
+ echo "$*" >> "$here/calls"
+ \(body)
echo "stub gh: unexpected call: $*" >&2
exit 99
- """#.write(to: gh, atomically: true, encoding: .utf8)
+ """.write(to: gh, atomically: true, encoding: .utf8)
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: gh.path)
- let scriptURL = root.appendingPathComponent("plan.sh")
+ let scriptURL = root.appendingPathComponent("step.sh")
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
let outputURL = root.appendingPathComponent("output")
let errURL = root.appendingPathComponent("stderr")
- for url in [outputURL, errURL] { fm.createFile(atPath: url.path, contents: nil) }
+ for url in [outputURL, errURL, root.appendingPathComponent("calls")] { fm.createFile(atPath: url.path, contents: nil) }
let p = Process()
p.executableURL = URL(fileURLWithPath: "/bin/bash")
p.arguments = ["-e", scriptURL.path]
- p.environment = [
+ p.currentDirectoryURL = root
+ p.environment = environment.merging([
"PATH": "\(bin.path):/usr/bin:/bin", "TMPDIR": NSTemporaryDirectory(),
- "EVENT": "schedule", "NIGHTLY_INPUT": "", "GITHUB_REF": "refs/heads/main", "GITHUB_SHA": sha,
"GITHUB_REPOSITORY": "krishhgg/Insomnia", "GITHUB_OUTPUT": outputURL.path,
- ]
+ ]) { _, fixed in fixed }
// Capture to files rather than pipes: nothing to drain, nothing to deadlock.
let err = try FileHandle(forWritingTo: errURL)
defer { try? err.close() }
@@ -275,9 +349,9 @@ final class ReleaseWorkflowTests: XCTestCase {
let childExit = ProcessExit(p)
try p.run()
childExit.wait()
- return (p.terminationStatus,
- (try? String(contentsOf: outputURL, encoding: .utf8)) ?? "",
- (try? String(contentsOf: errURL, encoding: .utf8)) ?? "")
+ let read = { (name: String) in (try? String(contentsOf: root.appendingPathComponent(name), encoding: .utf8)) ?? "" }
+ return StepResult(status: p.terminationStatus, output: read("output"), stderr: read("stderr"),
+ calls: read("calls").split(separator: "\n").map(String.init))
}
/// Releases are ad-hoc signed and not notarized: the workflow reads no
From 988be587d3af9bfd78164b2460ede4bc51535d5c Mon Sep 17 00:00:00 2001
From: Krish Garg
Date: Sat, 10 Oct 2026 20:59:58 -0700
Subject: [PATCH 04/10] README: shorten it and move the details into docs and
agent skills
The README drops from 736 lines to about 255 and keeps every image. The
long, mandatory parts (what the installer changes, hotspot and tmux,
Chrome throttling, configuration and privacy) are collapsed.
The detail moves, unchanged apart from em dashes and curly quotes, into
docs/install.md, docs/lid-close.md, docs/recovery.md and docs/extras.md.
skills/install-insomnia and skills/maintain-insomnia are agent playbooks
that follow those pages, and the README's install prompt points at the
install skill.
The README keeps the Install section with the Apple Silicon requirement,
the Build from source heading install.sh names, the four sudoers commands
and the microphone sentence. ReleaseWorkflowTests now checks the channel
and v0.1.0 text in docs/install.md, and the Greptile sudoers rule covers
that page.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.greptile/config.json | 4 +-
CONTRIBUTING.md | 8 +
README.md | 722 +++---------------
.../InsomniaTests/ReleaseWorkflowTests.swift | 24 +-
docs/extras.md | 117 +++
docs/install.md | 272 +++++++
docs/lid-close.md | 130 ++++
docs/recovery.md | 120 +++
docs/spec.md | 9 +-
skills/install-insomnia/SKILL.md | 44 ++
skills/maintain-insomnia/SKILL.md | 59 ++
11 files changed, 896 insertions(+), 613 deletions(-)
create mode 100644 docs/extras.md
create mode 100644 docs/install.md
create mode 100644 docs/lid-close.md
create mode 100644 docs/recovery.md
create mode 100644 skills/install-insomnia/SKILL.md
create mode 100644 skills/maintain-insomnia/SKILL.md
diff --git a/.greptile/config.json b/.greptile/config.json
index 37b79133..45bef947 100644
--- a/.greptile/config.json
+++ b/.greptile/config.json
@@ -68,8 +68,8 @@
{
"id": "sudoers-rule-is-four-exact-lines",
"severity": "high",
- "scope": ["scripts/install.sh", "scripts/uninstall.sh", "Sources/Insomnia/Core/SleepGuard.swift", "README.md", "SECURITY.md", "docs/spec.md"],
- "rule": "The file install.sh writes to /etc/sudoers.d/insomnia grants NOPASSWD for exactly these four commands and nothing else: /usr/bin/pmset -a disablesleep 1, /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -b lowpowermode 1, /usr/bin/pmset -b lowpowermode 0. No wildcard, no ALL, no bare /usr/bin/pmset, no other command, and it is validated with visudo -cf before it is installed. SleepGuard.swift runs only those four argument lists, and README.md, SECURITY.md and docs/spec.md list the same four."
+ "scope": ["scripts/install.sh", "scripts/uninstall.sh", "Sources/Insomnia/Core/SleepGuard.swift", "README.md", "docs/install.md", "SECURITY.md", "docs/spec.md"],
+ "rule": "The file install.sh writes to /etc/sudoers.d/insomnia grants NOPASSWD for exactly these four commands and nothing else: /usr/bin/pmset -a disablesleep 1, /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -b lowpowermode 1, /usr/bin/pmset -b lowpowermode 0. No wildcard, no ALL, no bare /usr/bin/pmset, no other command, and it is validated with visudo -cf before it is installed. SleepGuard.swift runs only those four argument lists, and README.md, docs/install.md, SECURITY.md and docs/spec.md list the same four."
},
{
"id": "behavior-changes-come-with-regression-tests",
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4b52e283..d53aa6e7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -43,3 +43,11 @@ certification. Real-machine validation requires a supervised, separately
approved run on a ventilated surface with recoverable work. Record the exact
revision, macOS/hardware versions, observed result, and remaining limitations
in the release validation record. Never stress or heat a machine in a bag.
+
+The app icon is the menu bar's open eye on a charcoal tile.
+[AppIconArtwork](Sources/Insomnia/UI/AppIconArtwork.swift) draws it from the
+same [vector geometry](Sources/Insomnia/UI/EyeMarkGeometry.swift) as the menu
+bar's closed eye, which opens while a session runs. After changing the
+artwork, run `./scripts/generate-app-icon.sh`. It regenerates the packaged PNG
+and ICNS assets and the README's SVG offline, with Xcode's swiftc and
+iconutil.
diff --git a/README.md b/README.md
index 7f523878..a9d427e7 100644
--- a/README.md
+++ b/README.md
@@ -9,14 +9,15 @@
- A native macOS menu bar app for timed awake sessions. Pick a duration for your
- long-running work, choose what happens when the lid closes, and see when
- recovery needs your attention.
+ A macOS menu bar app for timed awake sessions. Set how long your Mac stays
+ awake, choose what happens when the lid closes, and see when something could
+ not be undone.
@@ -27,8 +28,8 @@
-> **Use a stable, well-ventilated surface—not a closed bag.** Insomnia is
-> experimental software. Recovery can fail; a running timer is not a safety
+> **Use a stable, well-ventilated surface, not a closed bag.** Insomnia is
+> experimental. Recovery can fail, and a running timer is not a safety
> guarantee.
> [Validation status](docs/release-validation.md) · [Apple's ventilation guidance](https://support.apple.com/en-us/102336)
@@ -38,133 +39,26 @@
## Install
-Requires **macOS 26 or later on an Apple Silicon Mac**.
-
-Insomnia is published on two channels on the
-[releases page](https://github.com/krishhgg/Insomnia/releases):
-
-- Stable: the release GitHub marks Latest, tagged `v`. Use this one
- unless you want to try changes that are not in a stable release yet.
-- Nightly: prereleases tagged `nightly--`, built automatically
- from `main` once a day when it has new commits. Newest code, experimental,
- and possibly broken. A nightly is never marked Latest.
-
-Each release's notes give the exact commands for that release. One release
-differs from the steps below: `v0.1.0`, the first stable release, was packaged
-by hand from the maintainer's installed app, not by the Release workflow. It
-has no attestation, and its zip installs with
-`./scripts/install.sh --app ./Insomnia.app`. If the release you picked is
-`v0.1.0` (its zip is `Insomnia-0.1.0-macos-arm64.zip`), follow the install
-steps in its release notes instead of steps 2 and 3.
-
-The rest of this README describes releases built by the Release workflow and
-builds of the current source, not `v0.1.0`. The `README.md` in the `v0.1.0`
-zip describes what that release installs, how its recovery works and its
-limits. In particular, the installer's version check, the recovery script
-sealed inside the app and the code requirement the recovery agent pins,
-described below, are not in `v0.1.0`.
+Requires macOS 26 or later on an Apple Silicon Mac.
Paste this into your coding agent:
```text
-Install the latest stable Insomnia release from https://github.com/krishhgg/Insomnia by following its README. If a step needs my password, give me the command to run in Terminal.
+Install the latest stable Insomnia release by following https://github.com/krishhgg/Insomnia/blob/main/skills/install-insomnia/SKILL.md. If a step needs my password, give me the command to run in Terminal.
```
-For the newest nightly instead, write "the newest Insomnia nightly
-prerelease" in place of "the latest stable Insomnia release".
-
-Or run it yourself (`gh` is the [GitHub CLI](https://cli.github.com)):
-
-1. Download the zip and `SHA256SUMS` of the release you chose into an empty
- folder, from the releases page or with `gh`. For the stable release, the
- one marked Latest:
-
- ```bash
- gh release download --repo krishhgg/Insomnia --pattern '*.zip' --pattern SHA256SUMS
- ```
-
- For the newest nightly, paste this whole block. It reads every page of
- the release list and picks the most recently published nightly. It
- downloads nothing, and returns an error, when it cannot read the list,
- when no nightly has been published yet, or when the tag it picked is not
- in the `nightly--<12-character commit>` form. It never falls
- back to the stable release.
-
- ```bash
- insomnia_nightly() {
- local list tag
- list="$(gh api --paginate 'repos/krishhgg/Insomnia/releases?per_page=100' \
- --jq '.[] | select(.prerelease and (.draft | not) and (.tag_name | startswith("nightly-")))
- | "\(.published_at) \(.tag_name)"')" ||
- { echo "Could not read the release list. Nothing was downloaded." >&2; return 1; }
- tag="$(printf '%s\n' "$list" | sort | tail -n 1)"
- tag="${tag#* }"
- if [ -z "$tag" ]; then
- echo "No nightly has been published yet. Nothing was downloaded." >&2
- echo "Wait for the next daily build, or build from source." >&2
- return 1
- fi
- if ! printf '%s\n' "$tag" | grep -Eqx 'nightly-[0-9]{8}-[0-9a-f]{12}'; then
- printf 'Unexpected nightly tag: %s. Nothing was downloaded.\n' "$tag" >&2
- return 1
- fi
- gh release download "$tag" --repo krishhgg/Insomnia --pattern '*.zip' --pattern SHA256SUMS
- }
- insomnia_nightly
- ```
-
- If the releases page has no release yet, build from source (below).
-2. Verify the download. `` is the zip's file name. `` is
- `refs/tags/v` for a stable release or `refs/heads/main` for a
- nightly. `` is the full 40-character commit the release was built
- from: its release notes name it after "Built from" and carry this command
- filled in. A nightly's tag ends with the first 12 characters of it.
-
- ```bash
- shasum -a 256 -c SHA256SUMS
- gh attestation verify -R krishhgg/Insomnia \
- --signer-workflow krishhgg/Insomnia/.github/workflows/release.yml \
- --source-ref --source-digest
- ```
-
- The first command checks the zip against the `SHA256SUMS` downloaded with
- it. The second checks that this repository's Release workflow built this
- exact zip from that commit for that ref, so a nightly cannot pass for a
- stable release. Every nightly is built for `refs/heads/main`, so only
- `--source-digest` keeps another nightly's zip from passing: do not leave
- it out, and do not shorten the commit to the 12 characters in the tag.
-
-3. Unzip and run the installer that comes in the zip:
-
- ```bash
- ditto -x -k .
- cd
- ./install.sh --allow-unverified-origin --app ./Insomnia.app
- open "$HOME/Applications/Insomnia.app"
- ```
-
- Releases are ad-hoc signed and not notarized. The installer can check that
- the bundle is intact but not who made it, so it refuses to install without
- `--allow-unverified-origin`, which says you ran the two commands in step 2.
- macOS blocks the first launch of a downloaded copy until you allow it in
- System Settings > Privacy & Security.
-
-The installer checks the bundle's signature, identifier and version before it
-asks for anything (the `v0.1.0` installer checks the signature and
-identifier, not the version). It then installs the app and a background recovery agent,
-and asks for administrator access to install a narrowly scoped sudoers rule. It grants
-**your user account**, not just Insomnia, passwordless access to four
-power-setting commands. Review that permission before installing.
-
-Release zips are built for arm64 only, and their `install.sh --app` stops on
-an Intel Mac. On Intel, building from source (below) is the only option, and
-it is untested there.
-
-### Build from source (experimental)
-
-Requires **Xcode with Swift 6.2 or later**. Clone the latest stable tag, or a
-nightly tag for the newest code, rather than `main`. If the releases page has
-no release yet, leave out `--branch v` to build `main`:
+For the newest nightly, write "the newest Insomnia nightly prerelease" in
+place of "the latest stable Insomnia release". Nightlies are built from `main`
+on each day it has new commits. They have the newest code and may be broken.
+
+To install a release yourself, follow [docs/install.md](docs/install.md). It
+covers both channels, how to check the download, and `v0.1.0`, which installs
+differently.
+
+### Build from source
+
+Needs Xcode with Swift 6.2 or later. Replace `v` with the latest
+stable tag, or a nightly tag for the newest code:
```bash
git clone --branch v --depth 1 https://github.com/krishhgg/Insomnia.git
@@ -173,35 +67,19 @@ cd Insomnia
open "$HOME/Applications/Insomnia.app"
```
-`scripts/install.sh` builds the same bundle the release workflow builds
-(`scripts/build-app.sh`), ad-hoc signed, and installs it the same way. It
-builds only when it runs from a checkout's `scripts` folder, with
-`Package.swift` one level up, and then runs the `build-app.sh` beside it. The
-`install.sh` from a release zip stops and asks for `--app` instead, even when
-a `build-app.sh` was added to its folder after unpacking. A checkout of
-`v0.1.0` has the installer of that release; its README describes it.
-[docs/releasing.md](docs/releasing.md) describes the release pipeline.
-
-Exactly what gets installed
-
-This describes releases built by the Release workflow and builds of the
-current source. `v0.1.0` installs an older layout. Its app has no
-`backstop.sh` inside it: its installer copies `backstop.sh` into
-`~/Library/Application Support/Insomnia/`, and its recovery agent runs that
-copy with `/bin/bash` without checking any code signature. The sealed script,
-the pinned code requirement and the refusal of an edited or re-signed bundle
-described below do not apply to it, and neither does the upgrade procedure.
-Its sudoers rule grants the same four commands. The `README.md` in its zip
-describes what it installs and its limits.
-
-| Location | Purpose |
-| --- | --- |
-| `~/Applications/Insomnia.app` | The menu bar app, with `backstop.sh` sealed inside it at `Contents/Resources` |
-| `~/Library/Application Support/Insomnia/` | Configuration and the session/recovery journals |
-| `~/Library/LaunchAgents/com.insomnia.backstop.plist` | Per-user recovery agent: verifies the app's code signature, then runs the sealed `backstop.sh` |
-| `~/Library/Logs/Insomnia/` | `insomnia.log` and `handoffs.log`, each capped at 1 MiB with one older copy kept as `.1`, unless you replace it with a symlink |
-| `/etc/sudoers.d/insomnia` | Permission for the four commands below |
+What the installer changes on your Mac
+
+The installer needs your password, because the sudoers rule is a system
+file. It installs:
+
+- The app, at `~/Applications/Insomnia.app`.
+- A **recovery agent**, a `launchd` job that runs every minute. If the app is
+ gone when a session should have ended, the agent tries to undo the
+ session's changes.
+- A **sudoers rule** at `/etc/sudoers.d/insomnia`. A sudoers rule decides who
+ can run what with `sudo`. This one lets your user account, not just
+ Insomnia, run these four commands without a password:
```text
/usr/bin/pmset -a disablesleep 1
@@ -210,90 +88,29 @@ describes what it installs and its limits.
/usr/bin/pmset -b lowpowermode 0
```
-The grant is available to other processes running as your user. Insomnia is not
-sandboxed. The app, scripts, and journals are local; hotspot passwords use the
-login Keychain, not the configuration file.
-
-The recovery agent runs at login and every 60 seconds. Its command line pins
-the installed bundle's code requirement (for an ad-hoc build, the cdhash of
-that build) and runs `codesign --verify --strict` against it before executing
-the `backstop.sh` sealed inside the bundle. An edited bundle or script fails
-that check: the agent writes one line to `insomnia.log` and runs nothing until
-you reinstall. So no other account can edit it, the installer removes group
-and other write permission and every ACL from the bundle it installs. The
-signature covers neither, so the bundle still verifies, and extended
-attributes such as a download's quarantine flag are kept. No executable is
-kept in a writable support directory. The plist in `~/Library/LaunchAgents`
-is still a per-user file that any program running as you can edit, like
-every LaunchAgent; the app rewrites it at the next session start when it does
-not match, which is a repair, not a tamper check.
-
-What the app pins is the requirement of the code it is itself running, read
-through the Security framework after checking that the bundle on disk is still
-that code and still passes the agent's check. A bundle whose sealed script was
-edited, or that was re-signed under the running app, is refused rather than
-pinned: the app does not start a session, or reports the end as incomplete,
-and names the reason, until you reinstall. With ad-hoc signatures this guards
-against accidental edits and against the app relaying a tampered bundle into
-the agent, not against a process running as you: that process can edit the
-plist, load its own agent, quit the app and launch a replacement, and run the
-four `pmset` commands itself.
-
-An upgrade asks the running app to quit and stops if it refuses. The new
-bundle is built in a staging directory next to the app and moved into place in
-the same step that replaces the recovery agent. That step starts only after
-`launchctl print` confirms the previous agent is unloaded; otherwise nothing is
-replaced. If the new agent cannot be loaded, or its plist cannot be saved, the
-installer unloads it, waits for `launchctl print` to confirm that, and puts the
-previous bundle back, so the loaded agent always matches the installed app. A
-bundle that cannot be moved during that step is handled the same way: the
-previous bundle goes back and its agent is loaded again. If the previous bundle
-itself cannot be moved back, nothing is deleted: it stays at
-`~/Applications/.Insomnia.app.previous`, the installer prints the two commands
-that put it back and load its agent, and until then the agent finds no app, so
-run them or rerun the installer before you log out. If
-the unload is not confirmed, the new bundle stays with the agent that pins it
-and the installer asks you to rerun it. After that, or after an install killed
-in the middle of that step, the next run keeps whichever bundle the agent's
-plist on disk pins. It does that only after its own recovery step succeeds:
-while recovery is unresolved, an agent the earlier run left loaded may be the
-one retrying it, so the installer stops without unloading that agent or
-moving either bundle. Once recovery succeeds, it unloads that agent, moves the
-bundle back and loads the plist on disk again, and it stops if `launchctl
-print` does not confirm the unload or the reload. Unresolved recovery prevents
-replacing either; follow the reported instructions before retrying. The
-installer checks the sudoers rule again once it holds the recovery lock, and
-stops if the rule is gone, as after an `uninstall.sh` that took the lock first.
-Each call it makes to `sudo`, `pgrep`, `launchctl` or `codesign` while it
-holds the lock has a 30 s limit, which a supervising process enforces even
-if the installer is killed meanwhile. The call keeps the lock until it has
-exited or been stopped, so no `launchctl bootout` it started is still
-running once the lock is released. A call that does not answer in time gets SIGTERM, then SIGKILL
-one to two seconds later, and the install stops, so the lock is released and the app
-and the agent's backstop can take it again to undo a session. `sudo` only
-ever gets SIGTERM: one that ignores it keeps the lock until it ends, and the
-installer prints its pid.
+Any program running as you can use that rule too, so review it before you
+install. [docs/install.md](docs/install.md#what-gets-installed) lists every
+file and explains how upgrades replace them.
## Using it
-1. **Start:** click the eye in the menu bar, enter Days / Hours / Minutes, and
- press Enter.
-2. **Extend:** click the eye or countdown during a session and enter more time.
-3. **End early:** press and hold the end control beside the countdown.
-4. **Inspect or configure:** right-click for status, recovery warnings,
- **Settings**, and **Quit Insomnia**. Quitting requests session cleanup and
- can be refused while required recovery remains.
+1. **Start.** Click the eye in the menu bar, enter days, hours and minutes,
+ and press Enter.
+2. **Add time.** Click the eye or the countdown during a session.
+3. **End early.** Press and hold the end control next to the countdown.
+4. **Settings and status.** Right-click the eye. Quit asks Insomnia to undo
+ the session first, and it refuses while something is still waiting to be
+ undone.
-You do not need to close the lid to use a timed session. Opening the lid does
-not end it, and a sleeping display is not the same as a sleeping Mac.
+You don't have to close the lid, and opening it doesn't end the session.
+Below 40% battery Insomnia turns on Low Power Mode, and below 10% it ends the
+session. Heat works the same way: serious turns on Low Power Mode, critical
+ends the session. These rules only work while the app runs.
-Before the first session, review the settings. Some lid actions are on by
-default, including pausing the apps on the freeze list (Slack, WhatsApp and
-Discord) while the lid is closed; pausing every other Dock app is off until
-you turn it on. Start with a short, supervised session on a ventilated surface
-and check the status menu and `~/Library/Logs/Insomnia/insomnia.log` afterward.
+Before your first session, check Settings. Then run a short session you can
+watch, and read `~/Library/Logs/Insomnia/insomnia.log` afterward.
## What happens when the lid closes
@@ -301,123 +118,30 @@ and check the status menu and `~/Library/Logs/Insomnia/insomnia.log` afterward.
-During a session, Insomnia turns the display and keyboard backlight off
-(saving their brightness first), pauses the apps on the freeze list (and, if
-you opt in, every other Dock app that is not an agent app), checks whether
-Docker Desktop is idle before pausing it, and saves then mutes audio.
-Reopening the lid attempts to undo those lid actions. If the lid opens
-while Insomnia is still checking Docker, Docker is left running and the undo
-starts right away. **The timer keeps counting down while the lid is
-closed**; only its on-screen redraw pauses, also for a session started with
-the lid already closed.
-
-The display step exists because the sleep guard stops macOS from doing it:
-with sleep disabled, closing the lid no longer turns the panel or the keys off
-by itself. Insomnia sets both to zero and restores them when the lid opens.
-Both go through private macOS frameworks. The display calls run only on a
-macOS major version they were measured on (26). The keyboard calls run only
-while the private keyboard class has the method signatures measured on 26,
-on whatever version. If either check refuses a device, Insomnia leaves it
-alone and Settings says why under the toggle. A level saved before an update
-that the check now refuses stays saved for a version that can restore it, and
-the menu says to set it with the brightness keys meanwhile. That version leaves
-a level you set by hand alone, and decides only on a reading taken with the
-display awake and the keys not dimmed. Once Insomnia's own Low Power Mode has
-been on over the saved display level, it leaves that level undecided until the
-Mac restarts, through relaunches of the app, and until then a lid close leaves
-that display lit and only asks it to sleep. The same goes when the recovery
-agent switches that mode off before Insomnia starts again, and when Insomnia
-switches off a mode still claimed from before a restart. That mode may read
-off then, yet it may have gone off only a moment before, so the level waits
-for the next restart even when the mode has been off for days. Once it has seen
-that display lit above zero, it never writes the saved level over a zero you
-set by hand, also after a relaunch or a restart. It cannot tell that zero
-from one auto-brightness left under a closing lid, so the saved level stays
-undecided, with nothing written, until you raise the display above zero. If Insomnia cannot record that it saw the display lit, or that the
-saved level is settled, Quit waits until it can.
-The display comes back to the brightness sampled while the lid was open, not
-the reading at the moment of closing (auto-brightness has already dimmed the
-panel under the closing lid by then, and Low Power Mode rescales it), and if
-Insomnia's own Low Power Mode was on while the lid was closed the value is
-written once more when the mode ends. If Insomnia is not running when you open
-the lid, press the brightness-up key.
-
-The defaults are worth knowing:
-
-- **Selected apps:** Slack, WhatsApp, and Discord are on the freeze list.
- Configured agent apps are excluded from this ordinary list.
-- **Every other app:** "Freeze every other app while the lid is closed" is
- off, so a fresh install pauses the freeze list only. Turn it on to also pause
- every Dock app that is not an agent app, an Apple app, Docker Desktop or a
- built-in protected app (editors, terminals, browsers, AI apps, password
- managers, local databases, Tailscale, local model servers; JetBrains IDEs by
- bundle-id prefix), so only agents keep running with the lid shut. Menu-bar
- apps are never picked up automatically; add them to the freeze list if you
- want them paused. Settings shows a "Would freeze now" line listing what the
- automatic scope would pause at that moment.
-- **Meeting, recording and dictation apps:** never frozen by "Freeze every
- other app", with their helper apps: Zoom, Microsoft Teams (new and classic),
- Webex (and the older Webex Meetings app), FaceTime, Wispr Flow, Granola,
- Otter, OBS and Loom. Freezing one ends the call, the recording or the
- meeting notes when the lid closes. Putting one on the freeze list by hand
- still freezes it, except FaceTime, which is an Apple app.
-- **Docker rule:** off. Turn it on to pause Docker Desktop on lid close when
- no container is running. The local Desktop socket is asked once to pick
- Docker up and once more right before the pause; a busy answer, a failed
- `docker ps` or a timeout at either point leaves Docker running. A container
- that starts between the second check and the pause is still paused with
- Desktop, so leave the rule off for Docker workloads an unexpected pause
- would hurt.
-- **Mute on close:** on, so sound stops when the lid closes. Lid open
- restores each output that was muted, even if another one is in use by then.
- An output that is not connected stays muted until it reconnects: Insomnia
- restores it then if it is running, or at the next launch. Ending or
- quitting a session does not wait for it. The end notification and the menu
- name it, and the menu's "Stop waiting for " leaves it as it is. A
- restore that fails on a connected output is retried by Insomnia while it
- runs, and at the next launch.
-- **Microphone:** on Mac laptops with Apple silicon or a T2 chip, closing the
- lid disconnects the built-in microphone in hardware. Recording a meeting
- with the lid closed needs AirPods or an external mic; Settings says the same
- next to the lid-close options.
-- **Display and keyboard backlight:** on ("Turn off the display and keyboard
- backlight" in Settings). Both values are saved to the journal before they
- are changed.
-- **Low Power Mode while the lid is closed:** on. With sleep disabled a closed
- Mac otherwise keeps running at full speed; the mode is switched off again
- when the lid opens, unless a battery or thermal rule still wants it.
-- **Battery rules:** below 40% on battery, request Low Power Mode; below 10%,
- end the session. A battery that is present but cannot be read on two
- consecutive reads while on battery also ends the session, with a
- notification saying why: the floor cannot be applied to a level nobody can
- read. One failed read is tolerated, and the level is re-read every 30 s
- until it is readable again. A desktop has no battery and no floor. Serious
- thermal state requests Low Power Mode; critical thermal state ends the
- session. These rules require the app to be running.
- Setting the end floor to 0 turns the battery end off. Otherwise the end
- floor stays below the Low Power Mode floor. The Settings steppers move the
- other floor when the two would cross, and a hand-edited `config.json` with
- the floors out of order is corrected at launch, and logged, by raising the
- Low Power Mode floor.
-
-Upgrading from an earlier build changes two of these once. On the first
-launch of this version, a config.json saved by an earlier build gets "Freeze
-every other app" turned off and "Mute audio on lid close" turned on, and
-Insomnia posts a notification naming what changed. Settings shows the same
-line at the top of Lid-close actions until you dismiss it, for anyone with
-notifications off. The toggles are right below it. config.json records that
-the update ran (`lidCloseDefaultsApplied`), so a setting you turn back stays
-the way you set it. A fresh install starts with the new defaults and no
-notice.
-
-To exercise the lid actions without closing the lid, run
-`scripts/simulate-lid.sh closed` and then `scripts/simulate-lid.sh open` during
-a session; the app runs the same actions it would on a real lid event. Only a
-build with the file watcher compiled in reads that trigger: a debug build, or
-a release build installed with `INSOMNIA_LID_SIMULATION=1 ./scripts/install.sh`.
-A normal install has no watcher, so no program running as your user can replay
-the lid actions by writing a file. A build that has it logs "Lid simulation
-build" at launch and shows the same line in the status menu and in Settings.
+During a session, closing the lid does each of these, and opening it tries
+to undo them. The timer keeps counting down while the lid is closed.
+
+- **Display and keyboard light go off.** Insomnia saves their brightness
+ first. With sleep turned off, macOS no longer does this by itself.
+- **Apps on the freeze list are frozen.** Freezing sends an app's processes
+ `SIGSTOP`, which pauses them until `SIGCONT` on lid open. The list starts
+ with Slack, WhatsApp and Discord. "Freeze every other app" is off by
+ default. Even when on, it skips meeting, recording and dictation apps: Zoom,
+ Teams, Webex, FaceTime, Wispr Flow, Granola, Otter, OBS and Loom.
+- **Audio is muted.** Lid open unmutes each output Insomnia muted, even if you
+ switched to another one.
+- **Low Power Mode turns on.** It turns off when the lid opens, unless a
+ battery or heat rule still needs it.
+- **Docker Desktop is frozen when no container runs.** This one is off by
+ default.
+
+On Mac laptops with Apple silicon or a T2 chip, closing the lid disconnects
+the built-in microphone in hardware. Recording a meeting with the lid closed
+needs AirPods or an external mic.
+
+[docs/lid-close.md](docs/lid-close.md) has the details: how Docker is checked,
+how brightness is restored, the one-time settings change when you upgrade,
+and how to test lid actions without closing the lid.
## How recovery works
@@ -425,245 +149,69 @@ build" at launch and shows the same line in the status menu and in Settings.
-Insomnia records pending changes in a recovery journal. On session end, the app
-attempts to undo them. An independent `launchd` agent checks every minute and
-can attempt recovery after the app exits unexpectedly, once the saved deadline
-has passed. It leaves a valid, unexpired session alone.
-
-The app and backstop use the same lock so they do not restore and rewrite the
-journal over one another. Failed restoration keeps the relevant entries;
-unreadable journals are preserved instead of treated as clean. A session file
-that does not parse counts as expired and is renamed to
-`session.json.unreadable-