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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 194 additions & 60 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

67 changes: 49 additions & 18 deletions .github/workflows/smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,27 @@ name: Release smoke test
# core function (speech-to-text) through the app's non-interactive --self-test
# entry point. The job summary is the dated record for criterion R05.
#
# release.yml runs it against the DRAFT release, before it is made public, so a
# failing build is never offered to anyone. It also runs on demand for any tag.
# The downloaded assets are the same files a consumer gets after publishing; the
# publish job re-verifies the public download against its checksum.
# release.yml runs it against the verified workflow artifact attached to the
# DRAFT release, before the draft is made public, so a failing build is never
# offered to anyone. It also runs on demand for any published tag. The publish
# job re-verifies the public download against the tested artifact's checksum.
# Releases before the one that introduced --self-test fail the check below.

on:
workflow_dispatch:
inputs:
tag:
description: Release tag to test, draft or published, for example v1.6.5
description: Published release tag to test, for example v1.6.5
required: true
type: string
workflow_call:
inputs:
tag:
description: Release tag to test, draft or published
description: Release tag represented by the workflow artifact
required: true
type: string
artifact_name:
description: Workflow artifact containing the verified release files
required: true
type: string
outputs:
Expand All @@ -29,44 +33,71 @@ on:
value: ${{ jobs.smoke.outputs.dmg_sha256 }}

permissions:
# Drafts are only visible with write access; this workflow only downloads.
contents: write
contents: read

jobs:
smoke:
name: Install and transcribe
runs-on: macos-latest
runs-on: macos-15
timeout-minutes: 30
outputs:
dmg_sha256: ${{ steps.download.outputs.dmg_sha256 }}
dmg_sha256: ${{ steps.verify-download.outputs.dmg_sha256 }}
steps:
- name: Download the release DMG and checksum
id: download
- name: Validate release tag
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
echo "Tag must look like v1.2.3: $TAG" >&2
exit 1
fi

- name: Download verified workflow artifact
if: inputs.artifact_name != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.artifact_name }}
path: dl

- name: Download published release DMG and checksum
if: inputs.artifact_name == ''
shell: bash
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
mkdir -p dl
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir dl \
--pattern "OpenWritr-v*-macOS-arm64.dmg" \
--pattern "OpenWritr-v*-macOS-arm64.dmg.sha256"
(cd dl && shasum -a 256 -c ./*.dmg.sha256)
echo "dmg_sha256=$(shasum -a 256 dl/*.dmg | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"

- name: Verify downloaded DMG
id: verify-download
shell: bash
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
dmg="dl/OpenWritr-${TAG}-macOS-arm64.dmg"
checksum="$dmg.sha256"
if [[ ! -f "$dmg" || ! -f "$checksum" ]]; then
echo "Expected smoke-test DMG or checksum is missing for $TAG." >&2
exit 1
fi
(cd dl && shasum -a 256 -c "$(basename "$checksum")")
echo "DMG_PATH=$dmg" >> "$GITHUB_ENV"
echo "dmg_sha256=$(shasum -a 256 "$dmg" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"

- name: Install to /Applications
shell: bash
run: |
set -euo pipefail
dmg="$(ls dl/*.dmg)"
mountpoint="$RUNNER_TEMP/openwritr-dmg"
mkdir -p "$mountpoint"
hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mountpoint"
hdiutil attach "$DMG_PATH" -nobrowse -readonly -mountpoint "$mountpoint"
rm -rf /Applications/OpenWritr.app
ditto "$mountpoint/OpenWritr.app" /Applications/OpenWritr.app
hdiutil detach "$mountpoint"
Expand Down Expand Up @@ -109,7 +140,7 @@ jobs:
echo "|---|---|"
echo "| Date | $(date -u +%F) |"
echo "| Version | $version ($TAG) |"
echo "| Asset | $(basename "$(ls dl/*.dmg)") from the GitHub release (draft or public), checksum verified |"
echo "| Asset | $(basename "$DMG_PATH"), checksum verified before installation |"
echo "| macOS | $(sw_vers -productVersion) on $(uname -m) |"
echo "| Exercised | Installed to /Applications, Gatekeeper and notarization checks, transcribed a synthesized phrase with the shipped speech model and required the words: hello world smoke test |"
echo "| Result | passed |"
Expand Down
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,14 +148,15 @@ swift test

## Credentials and revocation

Repository and release credentials are held as GitHub Actions secrets and are
never in the tree. If one is exposed, revoke it at its source first, then update
the secret.
Release credentials are held as secrets in the GitHub `release` environment and
are never in the tree. Only the signing/notarization job uses that environment.
If one is exposed, revoke it at its source first, then update the environment
secret.

| Credential | Where it lives | If exposed |
|---|---|---|
| `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PWD` (Developer ID Application `.p12`) | Actions secrets | Revoke the certificate in the Apple Developer portal, issue a new one, re-export the `.p12`, update both secrets. Maintainer only. |
| `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_PASSWORD` | Actions secrets | Revoke the app-specific password at appleid.apple.com, create a new one, update `APPLE_APP_PASSWORD`. Maintainer only. |
| `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PWD` (Developer ID Application `.p12`) | GitHub `release` environment secrets | Revoke the certificate in the Apple Developer portal, issue a new one, re-export the `.p12`, update both secrets. Maintainer only. |
| `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_PASSWORD` | GitHub `release` environment secrets | Revoke the app-specific password at appleid.apple.com, create a new one, update `APPLE_APP_PASSWORD`. Maintainer only. |
| Local notary profile (`xcrun notarytool store-credentials`) | The maintainer's login keychain | Revoke the app-specific password as above and store the profile again. |
| User-entered provider API keys | The user's macOS Keychain (`KeychainStore`) | The user revokes the key with the provider and enters a new one in Settings. The repository holds none. |

Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,16 @@ The default comparison covers Apple Intelligence, Luna, Gemini Flash, MAI Flash,
The release flow builds a Developer ID signed app, notarizes and staples the app bundle, packages a
ZIP from that notarized app, then creates and notarizes a DMG. GitHub Releases for `v*` tags receive:

- notarized ZIP + SHA-256 checksum
- notarized DMG + SHA-256 checksum
- an additional `OpenWritr-{version}.dmg` (same signed/notarized bytes, renamed for [AppUpdater](#in-app-updates))

The required GitHub Actions secrets and what to do if one is exposed are listed in [AGENTS.md](AGENTS.md#credentials-and-revocation).
- `OpenWritr-v{version}-macOS-arm64.zip`
- `OpenWritr-v{version}-macOS-arm64.zip.sha256`
- `OpenWritr-v{version}-macOS-arm64.dmg`
- `OpenWritr-v{version}-macOS-arm64.dmg.sha256`
- `OpenWritr-{version}.dmg` (the same signed/notarized DMG bytes under the exact
name required by [AppUpdater](#in-app-updates))

The signing and notarization job reads its five credentials only from the
GitHub `release` environment. The required secret names and revocation steps are
listed in [AGENTS.md](AGENTS.md#credentials-and-revocation).

For local releases, copy the example environment and store a notary profile once:

Expand Down
186 changes: 124 additions & 62 deletions RELEASE_CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -1,80 +1,142 @@
# OpenWritr Release Checklist

Use this checklist for every tagged macOS release.

## 1. Preflight

- [ ] Working tree clean (`git status --short`)
- [ ] `CHANGELOG.md` has a section `## [x.y.z] — date` for this version and nothing left under Unreleased (the release workflow fails otherwise)
- [ ] `Info.plist` `CFBundleShortVersionString` and `CFBundleVersion` equal `x.y.z` (the release workflow fails otherwise)
- [ ] Developer ID identity available in keychain
- [ ] Notary profile available (`NOTARY_PROFILE=OpenWritr`) or Apple credentials set

## 2. Build + Sign + Notarize
The tag-triggered GitHub Actions workflow is the canonical release path. The
maintainer prepares and tags the release; the workflow builds, signs, notarizes,
creates the draft, smoke-tests the verified workflow artifact attached to it,
and publishes it.

## One-time repository setup

The maintainer must create a GitHub environment named `release`, restrict its
deployment branches and tags to selected tags matching `v*`, and configure these
environment secrets:

- `MACOS_CERTIFICATE`
- `MACOS_CERTIFICATE_PWD`
- `APPLE_ID`
- `APPLE_TEAM_ID`
- `APPLE_APP_PASSWORD`

These values must not remain repository-level Actions secrets after the
environment migration is verified. The environment and secret migration are
repository settings; the workflow cannot create or migrate them.

## 1. Maintainer: prepare the release

- [ ] Work on a pull-request branch; do not release unreviewed local changes.
- [ ] Set both `CFBundleShortVersionString` and `CFBundleVersion` in `Info.plist`
to `x.y.z`.
- [ ] Add a non-empty `## [x.y.z] — YYYY-MM-DD` section to `CHANGELOG.md`.
- [ ] Leave no release entries under an `Unreleased` heading.
- [ ] Run the required validation:

```sh
swift build -c release -Xswiftc -warnings-as-errors
swiftlint lint --strict
swift test
```

- [ ] Merge the release-preparation pull request and confirm the intended commit
is on `main`.

## 2. Maintainer: create the release tag

- [ ] Create and push `vx.y.z` at the prepared `main` commit. This explicit tag
push is the release trigger.
- [ ] Confirm the **Release macOS** workflow started for that tag.

Do not build or upload release assets manually during the normal path. Do not
dispatch the workflow for a new release instead of pushing its tag.

## 3. Workflow: build and publish

The workflow performs these actions without maintainer intervention:

1. Validates the triggering tag and commit, `Info.plist`, and changelog entry.
2. Uses the `release` environment to build, Developer ID-sign, notarize, staple,
and verify the app and disk image.
3. Resolves the live remote tag again, requires it still points to the triggering
commit, and passes the verified files to a separate job that creates or
updates a **draft** GitHub release. A new draft may be empty; a rerun may
contain only the five expected asset names. Any unexpected stale asset fails
the workflow instead of being published.
4. Downloads the same immutable workflow artifact without release-write access,
installs its DMG, verifies Gatekeeper and notarization, and runs the
transcription smoke test.
5. After the smoke test, resolves the live remote tag again and requires it
still points to the triggering commit and rechecks the exact five-asset set
before publishing the draft, then verifies the public DMG is the tested file.

The release contains exactly these five public assets:

- `OpenWritr-vx.y.z-macOS-arm64.zip`
- `OpenWritr-vx.y.z-macOS-arm64.zip.sha256`
- `OpenWritr-vx.y.z-macOS-arm64.dmg`
- `OpenWritr-vx.y.z-macOS-arm64.dmg.sha256`
- `OpenWritr-x.y.z.dmg` — the same notarized DMG bytes under the exact name
required by AppUpdater

Release notes come from the matching `CHANGELOG.md` section. Do not write or
replace them manually.

**Never attest `OpenWritr-x.y.z.dmg`.** OpenWritr intentionally has no updater
attestation policy; restoring one or attesting the update DMG can strand or crash
installed clients (see #31).

## 4. Maintainer: monitor and recover

- [ ] Confirm **Build signed and notarized macOS artifacts** passed.
- [ ] Confirm **Create or update the draft release** passed.
- [ ] Confirm **Smoke-test the release before publishing** passed.
- [ ] Confirm **Publish the release** passed. Its smoke-test job summary is the
`R05` record described in
[docs/release-smoke-tests.md](docs/release-smoke-tests.md).
- [ ] Confirm the release page is public and lists all five exact asset names.

If signing, notarization, networking, or a runner fails transiently, rerun the
existing tag with `workflow_dispatch`, selecting that `vx.y.z` tag as the
workflow ref. For example:

```sh
scripts/release_macos.sh
gh workflow run release.yml --ref vx.y.z
```

Expected outcome:
Using the tag as the workflow ref is required by the `release` environment's
`v*` deployment restriction. There is no independent version input: the workflow
derives the release identity from the triggering tag, checks out its fully
qualified `refs/tags/vx.y.z` ref, and verifies that `HEAD` is that tag's commit.
The rerun rebuilds the immutable tagged commit and may replace assets only while
the release remains a draft.

- Signed app bundle created
- App notarized and stapled
- Signed ZIP created at `dist/OpenWritr-macos.zip`
- `dist/OpenWritr-macos.zip.sha256` generated
- Signed DMG created at `dist/OpenWritr-macos.dmg`
- Notarization executed (not skipped)
- `dist/OpenWritr-macos.dmg.sha256` generated
The workflow refuses to overwrite an already-public release. If code, scripts,
metadata, release notes, or assets need a fix, prepare and tag a **new version**.
Never move or reuse the published tag. A failed draft may be deleted by the
maintainer before creating that new version.

## 3. Versioned Artifact Names
## 5. Optional local rehearsal or recovery

Replace `x.y.z` with release version.

```sh
cp dist/OpenWritr-macos.zip dist/OpenWritr-vx.y.z-macOS-arm64.zip
cp dist/OpenWritr-macos.zip.sha256 dist/OpenWritr-vx.y.z-macOS-arm64.zip.sha256
cp dist/OpenWritr-macos.dmg dist/OpenWritr-vx.y.z-macOS-arm64.dmg
cp dist/OpenWritr-macos.dmg.sha256 dist/OpenWritr-vx.y.z-macOS-arm64.dmg.sha256
```
Local release commands are optional diagnostics, not the canonical release
procedure and not a substitute for the tag-triggered workflow. They do not
create or publish a GitHub release.

## 4. Verification (must pass)
With a local Developer ID identity and notary profile configured:

```sh
unzip -q dist/OpenWritr-vx.y.z-macOS-arm64.zip -d /tmp/openwritr-verify
xcrun stapler validate /tmp/openwritr-verify/OpenWritr.app
spctl --assess --type execute --verbose /tmp/openwritr-verify/OpenWritr.app
xcrun stapler validate dist/OpenWritr-vx.y.z-macOS-arm64.dmg
spctl --assess --type open --context context:primary-signature --verbose dist/OpenWritr-vx.y.z-macOS-arm64.dmg
cp .release.env.example .release.env
version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Info.plist)"
scripts/release_macos.sh "$version"
```

Expected lines:

- `The validate action worked!`
- `accepted`
- `source=Notarized Developer ID`

Optional deep check:
The local script uses the versioned asset base
`dist/OpenWritr-v${version}-macOS-arm64`. Verify those local outputs directly:

```sh
hdiutil attach -readonly -nobrowse dist/OpenWritr-vx.y.z-macOS-arm64.dmg
codesign -dv --verbose=4 /Volumes/OpenWritr/OpenWritr.app
hdiutil detach /Volumes/OpenWritr
xcrun stapler validate .build/release/OpenWritr.app
spctl --assess --type execute --verbose=2 .build/release/OpenWritr.app
xcrun stapler validate "dist/OpenWritr-v${version}-macOS-arm64.dmg"
spctl --assess --type open --context context:primary-signature \
--verbose=2 "dist/OpenWritr-v${version}-macOS-arm64.dmg"
```

## 5. GitHub Release

- [ ] Push tag `vx.y.z`. The release workflow builds, signs, and notarizes, creates the GitHub release as a **draft**, smoke-tests the draft, and only then publishes it. If the smoke test fails the release stays a draft and nothing is public. Re-running the workflow for the same tag (`workflow_dispatch`) rebuilds the same commit, so it only recovers from a transient failure (runner, network, notarization service). A fix to code, scripts, or the changelog needs a **new version**: delete the draft, bump `Info.plist`, add a changelog entry, and tag again. A tag that already has a public release is refused
- [ ] Upload artifacts:
- `OpenWritr-vx.y.z-macOS-arm64.zip`
- `OpenWritr-vx.y.z-macOS-arm64.zip.sha256`
- `OpenWritr-vx.y.z-macOS-arm64.dmg`
- `OpenWritr-vx.y.z-macOS-arm64.dmg.sha256`
- [ ] Release notes are the changelog section for the version; the release workflow publishes them, so do not write them by hand

## 6. Post-Release Sanity

- [ ] Download DMG from release page
- [ ] Verify checksum
- [ ] Install and launch on a clean user profile or second machine
- [ ] Confirm app starts and prompts for permissions as expected
- [ ] The `Smoke-test the release before publishing` and `Publish the release` jobs of the release run passed; its job summary is the `R05` record ([docs/release-smoke-tests.md](docs/release-smoke-tests.md))
Do not upload locally produced files over a public release. Any recovered
release still goes through a new version and the canonical workflow.
Loading
Loading