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
116 changes: 116 additions & 0 deletions .github/workflows/nightly-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: Nightly Release

# Releases are built from `main` on a schedule instead of from a manually pushed
# tag: the job inspects commits since the latest stable release tag, and when
# there are any it creates the next patch tag and publishes that release. The
# tag-triggered Release workflow remains available as a manual fallback.
on:
schedule:
# 20:00 Asia/Shanghai == 12:00 UTC.
- cron: "0 12 * * *"
workflow_dispatch:

permissions:
contents: read

# Never let a nightly run overlap itself (a manually dispatched run and the
# scheduled run, or two slow runs). The newest run does not cancel one that is
# already publishing a release.
concurrency:
group: nightly-release
cancel-in-progress: false

jobs:
plan:
name: Plan nightly release
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
changed: ${{ steps.plan.outputs.changed }}
version: ${{ steps.plan.outputs.version }}
tag: ${{ steps.plan.outputs.tag }}
reason: ${{ steps.plan.outputs.reason }}
commit_count: ${{ steps.plan.outputs.commit_count }}
commit_summary: ${{ steps.plan.outputs.commit_summary }}
head_sha: ${{ steps.plan.outputs.head_sha }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true

- name: Decide whether main moved since the latest release
id: plan
run: |
set -euo pipefail
./scripts/nightly-release-plan.sh . origin/main | tee plan.txt
while IFS='=' read -r key value; do
case "$key" in
changed | version | reason | commit_count | commit_summary | head_sha | latest_tag)
if [ "$key" = "version" ] && [ -n "$value" ]; then
value="v$value"
fi
echo "$key=$value" >> "$GITHUB_OUTPUT"
;;
esac
done < plan.txt
rm -f plan.txt

- name: Report decision
env:
CHANGED: ${{ steps.plan.outputs.changed }}
VERSION: ${{ steps.plan.outputs.version }}
COMMITS: ${{ steps.plan.outputs.commit_count }}
SUMMARY: ${{ steps.plan.outputs.commit_summary }}
REASON: ${{ steps.plan.outputs.reason }}
run: |
if [ "$CHANGED" = "true" ]; then
{
echo "### Nightly release: ${VERSION}"
echo ""
echo "- Commits since the last release: ${COMMITS}"
echo "- Newest commits: ${SUMMARY}"
} >> "$GITHUB_STEP_SUMMARY"
echo "Releasing ${VERSION} from ${COMMITS} new commit(s)."
else
{
echo "### Nightly release: skipped"
echo ""
echo "${REASON}"
} >> "$GITHUB_STEP_SUMMARY"
echo "Skipping nightly release: ${REASON}"
fi

# Full repository checks + Swift tests before anything is tagged.
validate:
name: Release Candidate Tests
needs: plan
if: ${{ needs.plan.outputs.changed == 'true' }}
runs-on: macos-26
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Run repository checks
run: bash ./scripts/ci-basic-checks.sh

- name: Ensure Metal toolchain
run: xcodebuild -downloadComponent MetalToolchain

- name: Run unit tests
run: swift test

# Creates the tag on the current origin/main tip, then signs, verifies, and
# publishes through the same reusable pipeline the manual tag flow uses.
release:
name: Sign, Verify & Publish
needs: [plan, validate]
if: ${{ needs.plan.outputs.changed == 'true' }}
uses: ./.github/workflows/release-artifact.yml
with:
version: ${{ needs.plan.outputs.version }}
tag: ${{ needs.plan.outputs.tag }}
create_tag: true
secrets: inherit
278 changes: 278 additions & 0 deletions .github/workflows/release-artifact.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
# Reusable signing/build/verify/publish pipeline shared by the tag-triggered
# release workflow and the nightly release workflow. Keeping the steps here
# prevents the two entry points from drifting.
#
# The caller must provide the version and tag because a reusable workflow cannot
# create the tag it is releasing; see .github/workflows/release.yml and
# .github/workflows/nightly-release.yml.
name: Release Artifact

on:
workflow_call:
inputs:
version:
description: "Numeric bundle version, e.g. 0.0.46 (validated by release-version.sh)"
required: true
type: string
tag:
description: "Release tag, e.g. v0.0.46 (must already exist for the tag entry point)"
required: true
type: string
create_tag:
description: "Create and push the tag before building (nightly entry point)"
required: false
type: boolean
default: false
require_ancestor:
description: "Fail unless the tag commit is an ancestor of origin/main"
required: false
type: boolean
default: true

permissions:
contents: read

jobs:
release:
name: Sign, Verify & Publish
runs-on: macos-26
timeout-minutes: 75
environment: production
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Validate version and tag
env:
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
./scripts/release-version.sh "$RELEASE_TAG" >/dev/null
expected="$(./scripts/release-version.sh "$RELEASE_TAG")"
if [ "$expected" != "$RELEASE_VERSION" ]; then
echo "Tag $RELEASE_TAG does not match version $RELEASE_VERSION" >&2
exit 1
fi

- name: Require a SemVer tag on main
if: ${{ inputs.require_ancestor }}
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
git fetch origin main
if ! git merge-base --is-ancestor "$RELEASE_TAG^{commit}" origin/main; then
echo "Release commit $(git rev-parse "$RELEASE_TAG^{commit}") is not on origin/main"
exit 1
fi

- name: Create and push release tag
if: ${{ inputs.create_tag }}
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
git fetch origin main
if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then
echo "Tag $RELEASE_TAG already exists on origin; refusing to move it"
exit 1
fi
tip="$(git rev-parse origin/main)"
if [ "$tip" != "$(git rev-parse HEAD)" ]; then
echo "Checked-out commit $(git rev-parse HEAD) is not the current origin/main tip $tip"
exit 1
fi
git tag -a "$RELEASE_TAG" "$tip" -m "Utter $RELEASE_TAG"
git push origin "refs/tags/$RELEASE_TAG"

- name: Require release credentials
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
missing=()
for name in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD; do
if [ -z "${!name:-}" ]; then
missing+=("$name")
fi
done
if [ ${#missing[@]} -ne 0 ]; then
echo "Missing protected release secrets: ${missing[*]}"
exit 1
fi

- name: Import signing certificate
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
CERT_PATH="$RUNNER_TEMP/certificate.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
KEYCHAIN_PASS="$(openssl rand -hex 16)"

echo "$APPLE_CERTIFICATE_P12" | base64 --decode > "$CERT_PATH"
security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" \
-A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: \
-k "$KEYCHAIN_PASS" "$KEYCHAIN_PATH"

CERT_PEM="$RUNNER_TEMP/certificate.pem"
# OpenSSL 3 disables legacy RC2-40-CBC used by older PKCS#12 exports.
# Try modern decode first; fall back to -legacy for existing secrets.
if ! openssl pkcs12 -in "$CERT_PATH" -clcerts -nokeys \
-passin "pass:${APPLE_CERTIFICATE_PASSWORD}" -out "$CERT_PEM" 2>/dev/null
then
openssl pkcs12 -in "$CERT_PATH" -clcerts -nokeys -legacy \
-passin "pass:${APPLE_CERTIFICATE_PASSWORD}" -out "$CERT_PEM"
fi
SIGN_CERT_SHA256="$(openssl x509 -in "$CERT_PEM" -noout \
-fingerprint -sha256 | cut -d= -f2 | tr -d ':')"
IDENTITY="$(security find-identity -p codesigning "$KEYCHAIN_PATH" \
| sed -n 's/.*"\(.*\)".*/\1/p' \
| head -1)"
if [ -z "$IDENTITY" ]; then
echo "No code-signing identity found in the release certificate"
exit 1
fi
if [[ "$IDENTITY" != "Developer ID Application:"* ]]; then
sudo security add-trusted-cert -d -r trustRoot \
-k "$KEYCHAIN_PATH" "$CERT_PEM"
fi
security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
rm -f "$CERT_PATH" "$CERT_PEM"
echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
echo "SIGN_CERT_SHA256=$SIGN_CERT_SHA256" >> "$GITHUB_ENV"
echo "Signing identity imported: $IDENTITY"

- name: Ensure Metal toolchain
run: xcodebuild -downloadComponent MetalToolchain

- name: Build signed app and DMG
env:
RELEASE_VERSION: ${{ inputs.version }}
run: |
./scripts/build-app.sh \
--version="$RELEASE_VERSION" \
--sign="$SIGN_IDENTITY"

- name: Classify and verify signed artifact
env:
RELEASE_VERSION: ${{ inputs.version }}
run: |
SIGNATURE="$(codesign -dvvv dist/Utter.app 2>&1)"
if ! grep -Fqx "Authority=$SIGN_IDENTITY" <<<"$SIGNATURE"; then
echo "Built app authority does not match imported identity: $SIGN_IDENTITY"
exit 1
fi

VERIFY_ARGS=(
--app dist/Utter.app
--dmg "dist/Utter-$RELEASE_VERSION.dmg"
--version "$RELEASE_VERSION"
--expected-cert-sha256 "$SIGN_CERT_SHA256"
)
if grep -q '^Authority=Developer ID Application:' <<<"$SIGNATURE"; then
SIGNING_MODE=developer-id
VERIFY_ARGS+=(--require-developer-id)
elif grep -q '^TeamIdentifier=not set$' <<<"$SIGNATURE"; then
SIGNING_MODE=self-signed
VERIFY_ARGS+=(--require-self-signed)
else
echo "Unsupported non-Developer-ID signing identity"
exit 1
fi
echo "SIGNING_MODE=$SIGNING_MODE" >> "$GITHUB_ENV"
echo "Signing mode: $SIGNING_MODE" | tee -a "$GITHUB_STEP_SUMMARY"
./scripts/verify-release-artifact.sh "${VERIFY_ARGS[@]}"

- name: Notarize and staple DMG
if: env.SIGNING_MODE == 'developer-id'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
RELEASE_VERSION: ${{ inputs.version }}
run: |
missing=()
for name in APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD; do
if [ -z "${!name:-}" ]; then
missing+=("$name")
fi
done
if [ ${#missing[@]} -ne 0 ]; then
echo "Developer ID release is missing notarization secrets: ${missing[*]}"
exit 1
fi
DMG="dist/Utter-$RELEASE_VERSION.dmg"
xcrun notarytool submit "$DMG" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_PASSWORD" \
--wait --timeout 30m
xcrun stapler staple "$DMG"

- name: Verify distribution and checksum
env:
RELEASE_VERSION: ${{ inputs.version }}
run: |
DMG="dist/Utter-$RELEASE_VERSION.dmg"
VERIFY_ARGS=(
--app dist/Utter.app
--dmg "$DMG"
--version "$RELEASE_VERSION"
--expected-cert-sha256 "$SIGN_CERT_SHA256"
)
if [ "$SIGNING_MODE" = "developer-id" ]; then
VERIFY_ARGS+=(--require-developer-id --require-notarization)
else
VERIFY_ARGS+=(--require-self-signed)
fi
./scripts/verify-release-artifact.sh "${VERIFY_ARGS[@]}"
(
cd dist
shasum -a 256 "$(basename "$DMG")" > "$(basename "$DMG").sha256"
shasum -c "$(basename "$DMG").sha256"
)

- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
DMG="dist/Utter-$RELEASE_VERSION.dmg"
CHECKSUM="$DMG.sha256"
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists; refusing to replace immutable assets"
exit 1
fi
if [ "$SIGNING_MODE" = "self-signed" ]; then
RELEASE_NOTE=$'> [!WARNING]\n> This release is signed with the project self-signed certificate and is not Apple-notarized. macOS may require manual approval before opening it.'
else
RELEASE_NOTE=$'> [!NOTE]\n> This release is signed with Apple Developer ID and notarized by Apple.'
fi
gh release create "$RELEASE_TAG" \
--draft \
--title "Utter $RELEASE_TAG" \
--generate-notes \
--notes "$RELEASE_NOTE" \
--verify-tag
gh release upload "$RELEASE_TAG" "$DMG" "$CHECKSUM"

DOWNLOAD_DIR="$(mktemp -d)"
trap 'rm -r "$DOWNLOAD_DIR"' EXIT
gh release download "$RELEASE_TAG" \
--pattern "$(basename "$DMG")" \
--pattern "$(basename "$CHECKSUM")" \
--dir "$DOWNLOAD_DIR"
(
cd "$DOWNLOAD_DIR"
shasum -c "$(basename "$CHECKSUM")"
)
cmp "$DMG" "$DOWNLOAD_DIR/$(basename "$DMG")"
gh release edit "$RELEASE_TAG" --draft=false --latest
Loading
Loading