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/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: Release

on:
workflow_dispatch:
inputs:
version:
description: Stable version without the v prefix, for example 0.2.7
required: true
type: string
dry_run:
description: Build and validate without creating a GitHub Release
required: true
default: true
type: boolean

permissions:
contents: write

concurrency:
group: release
cancel-in-progress: false

jobs:
release:
name: Build signed update
runs-on: macos-15
timeout-minutes: 20
environment: release
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false

- name: Validate version
run: Scripts/validate-release.sh '${{ inputs.version }}'

- name: Build universal app
env:
CODEX_LIMITS_UNIVERSAL: 1
run: Scripts/build-app.sh

- name: Prepare release notes and archive
env:
GH_TOKEN: '${{ github.token }}'
VERSION: '${{ inputs.version }}'
run: |
set -euo pipefail
artifacts="$RUNNER_TEMP/release-artifacts"
archive="Codex-Limits-$VERSION.zip"
mkdir -p "$artifacts"
previous_tag=$(git tag --list 'v[0-9]*' --sort=-version:refname | head -n 1)
gh api --method POST "repos/$GITHUB_REPOSITORY/releases/generate-notes" \
-f tag_name="v$VERSION" \
-f target_commitish="$GITHUB_SHA" \
-f previous_tag_name="$previous_tag" \
--jq .body > "$artifacts/Codex-Limits-$VERSION.md"
ditto -c -k --sequesterRsrc --keepParent \
'.build/release/Codex Limits.app' \
"$artifacts/$archive"
echo "ARTIFACTS=$artifacts" >> "$GITHUB_ENV"
echo "ARCHIVE=$archive" >> "$GITHUB_ENV"

- name: Sign archive and feed
env:
SPARKLE_PRIVATE_KEY: '${{ secrets.SPARKLE_PRIVATE_KEY }}'
VERSION: '${{ inputs.version }}'
run: |
set -euo pipefail
test -n "$SPARKLE_PRIVATE_KEY"
printf '%s' "$SPARKLE_PRIVATE_KEY" | \
.build/universal-arm64/artifacts/sparkle/Sparkle/bin/generate_appcast \
--ed-key-file - \
--download-url-prefix \
"https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION/" \
--embed-release-notes \
--maximum-deltas 0 \
--critical-update-version '' \
"$ARTIFACTS"

- name: Validate update artifacts
run: |
set -euo pipefail
app='.build/release/Codex Limits.app'
codesign --verify --deep --strict "$app"
archs=$(lipo -archs "$app/Contents/MacOS/CodexLimits")
test "$archs" = 'x86_64 arm64' -o "$archs" = 'arm64 x86_64'
xmllint --noout "$ARTIFACTS/appcast.xml"
grep -q 'sparkle:edSignature' "$ARTIFACTS/appcast.xml"
grep -q 'sparkle:criticalUpdate' "$ARTIFACTS/appcast.xml"

- name: Upload dry-run artifacts
if: inputs.dry_run
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: Codex-Limits-${{ inputs.version }}
path: |
${{ env.ARTIFACTS }}/${{ env.ARCHIVE }}
${{ env.ARTIFACTS }}/appcast.xml
${{ env.ARTIFACTS }}/Codex-Limits-${{ inputs.version }}.md
if-no-files-found: error

- name: Create draft release
if: inputs.dry_run == false
env:
GH_TOKEN: '${{ github.token }}'
VERSION: '${{ inputs.version }}'
run: |
gh release create "v$VERSION" \
--draft \
--target "$GITHUB_SHA" \
--title "Codex Limits $VERSION" \
--notes-file "$ARTIFACTS/Codex-Limits-$VERSION.md" \
"$ARTIFACTS/$ARCHIVE" \
"$ARTIFACTS/appcast.xml"
15 changes: 15 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,17 @@ let package = Package(
products: [
.executable(name: "CodexLimits", targets: ["CodexLimits"])
],
dependencies: [
.package(
url: "https://github.com/sparkle-project/Sparkle",
exact: "2.9.5"
)
],
targets: [
.executableTarget(name: "CodexLimits"),
.executableTarget(
name: "CodexLimits",
dependencies: ["Sparkle"]
),
.testTarget(
name: "CodexLimitsTests",
dependencies: ["CodexLimits"],
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ The app keeps weak estimates out of guidance and Insights. The Usage remaining c
- Copies account usage samples to a private folder that you choose.
- Deletes all Codex Limits analytics history on this Mac and in the selected sync folder when you choose `Delete analytics history`.
- Refreshes on launch, after wake, when you open the menu, every ten minutes, or on request.
- Runs as a native SwiftUI menu-bar app with no third-party runtime dependencies.
- Runs as a native SwiftUI menu-bar app and uses Sparkle to verify and install signed updates.
- Does not redeem resets, change Codex settings, or control Tasks.

## How it works
Expand Down Expand Up @@ -131,7 +131,7 @@ The script creates an ad-hoc signed app at `.build/release/Codex Limits.app`. La
open ".build/release/Codex Limits.app"
```

This project offers no prebuilt or notarized app. Open `Package.swift` in Xcode to work on the source.
Stable releases include a universal app for Apple Silicon and Intel. The app is not Developer ID signed or notarized, so the first manual installation remains subject to macOS Gatekeeper. After that, the app can detect and install EdDSA-signed stable updates. Open `Package.swift` in Xcode to work on the source.

## Test

Expand All @@ -143,7 +143,7 @@ The tests use made-up usage data. Do not commit exported account data or local a

## Current limitations

- You must build the app from source.
- Existing 0.2.6 and older installations require one final manual update to a version that includes the in-app updater.
- Account and local values can differ because this Mac may not observe every Codex Task.
- Estimates need account readings near both ends of a time range and enough similar local work.
- `Analyze with Codex` appears only when Codex offers GPT-5.6 Luna with Medium reasoning.
Expand Down
16 changes: 14 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.6</string>
<string>0.2.7</string>
<key>CFBundleVersion</key>
<string>7</string>
<string>8</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key>
Expand All @@ -28,5 +28,17 @@
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>SUAllowsAutomaticUpdates</key>
<false/>
<key>SUEnableAutomaticChecks</key>
<true/>
<key>SUFeedURL</key>
<string>https://github.com/thrr87/codex-limits/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>3HnMDZs+eAgmWfY3G8N0OgGKaYX1O+opzEYIaiYBR58=</string>
<key>SURequireSignedFeed</key>
<true/>
<key>SUVerifyUpdateBeforeExtraction</key>
<true/>
</dict>
</plist>
64 changes: 60 additions & 4 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,75 @@ if [[ ${CODEX_LIMITS_QA:-0} == 1 ]]; then
build_args+=(-Xswiftc -DCODEX_LIMITS_QA)
fi

xcrun swift build "${build_args[@]}"
if [[ ${CODEX_LIMITS_UNIVERSAL:-0} == 1 ]]; then
for architecture in arm64 x86_64; do
scratch="$project_dir/.build/universal-$architecture"
xcrun swift build "${build_args[@]}" \
--triple "$architecture-apple-macosx14.0" \
--scratch-path "$scratch" \
--cache-path "$project_dir/.build/package-cache"
done
arm_release="$project_dir/.build/universal-arm64/arm64-apple-macosx/release"
intel_release="$project_dir/.build/universal-x86_64/x86_64-apple-macosx/release"
executable="$project_dir/.build/release/CodexLimits"
mkdir -p "${executable:h}"
lipo -create \
"$arm_release/CodexLimits" \
"$intel_release/CodexLimits" \
-output "$executable"
framework="$arm_release/Sparkle.framework"
else
xcrun swift build "${build_args[@]}"
executable="$project_dir/.build/release/CodexLimits"
framework="$project_dir/.build/release/Sparkle.framework"
fi

rm -rf "$app_dir"
mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources"
cp .build/release/CodexLimits "$app_dir/Contents/MacOS/CodexLimits"
mkdir -p \
"$app_dir/Contents/MacOS" \
"$app_dir/Contents/Resources" \
"$app_dir/Contents/Frameworks"
cp "$executable" "$app_dir/Contents/MacOS/CodexLimits"
install_name_tool -add_rpath \
@loader_path/../Frameworks \
"$app_dir/Contents/MacOS/CodexLimits"
ditto "$framework" "$app_dir/Contents/Frameworks/Sparkle.framework"
cp Resources/Info.plist "$app_dir/Contents/Info.plist"
if [[ -n ${CODEX_LIMITS_VERSION:-} ]]; then
/usr/libexec/PlistBuddy -c \
"Set :CFBundleShortVersionString $CODEX_LIMITS_VERSION" \
"$app_dir/Contents/Info.plist"
fi
if [[ -n ${CODEX_LIMITS_BUILD:-} ]]; then
/usr/libexec/PlistBuddy -c \
"Set :CFBundleVersion $CODEX_LIMITS_BUILD" \
"$app_dir/Contents/Info.plist"
fi
if [[ -n ${CODEX_LIMITS_FEED_URL:-} ]]; then
/usr/libexec/PlistBuddy -c \
"Set :SUFeedURL $CODEX_LIMITS_FEED_URL" \
"$app_dir/Contents/Info.plist"
fi
if [[ -n ${CODEX_LIMITS_PUBLIC_ED_KEY:-} ]]; then
/usr/libexec/PlistBuddy -c \
"Set :SUPublicEDKey $CODEX_LIMITS_PUBLIC_ED_KEY" \
"$app_dir/Contents/Info.plist"
fi
if [[ ${CODEX_LIMITS_QA:-0} == 1 ]]; then
/usr/libexec/PlistBuddy -c \
"Set :CFBundleIdentifier com.github.thrr87.CodexLimits.QA" \
"$app_dir/Contents/Info.plist"
/usr/libexec/PlistBuddy -c \
"Set :CFBundleDisplayName Codex Limits QA" \
"$app_dir/Contents/Info.plist"
/usr/libexec/PlistBuddy -c \
"Add :NSAppTransportSecurity dict" \
"$app_dir/Contents/Info.plist"
/usr/libexec/PlistBuddy -c \
"Add :NSAppTransportSecurity:NSAllowsLocalNetworking bool true" \
"$app_dir/Contents/Info.plist"
fi
codesign --force --sign - "$app_dir"
codesign --force --deep --sign - "$app_dir"
codesign --verify --deep --strict "$app_dir"

print -r -- "$app_dir"
58 changes: 57 additions & 1 deletion Scripts/qa-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ set -euo pipefail

project_dir=${0:A:h:h}
app_dir="$project_dir/.build/release/Codex Limits.app"
update_dir="$project_dir/.build/qa-update"
executable_pattern="$project_dir/.build/.*/Codex Limits.app/Contents/MacOS/CodexLimits"
relative_executable_pattern="\\.build/.*/Codex Limits\\.app/Contents/MacOS/CodexLimits"
update_executable_pattern="$update_dir/Codex Limits QA.app/Contents/MacOS/CodexLimits"
action=${1:-launch}

cleanup() {
pkill -f "$executable_pattern" 2>/dev/null || true
pkill -f "$relative_executable_pattern" 2>/dev/null || true
pkill -f "$update_executable_pattern" 2>/dev/null || true
if [[ -f "$update_dir/server.pid" ]]; then
kill "$(<"$update_dir/server.pid")" 2>/dev/null || true
fi
}

case "$action" in
Expand All @@ -21,8 +27,58 @@ case "$action" in
CODEX_LIMITS_QA=1 "$project_dir/Scripts/build-app.sh"
open "$app_dir"
;;
update)
cleanup
rm -rf "$update_dir"
mkdir -p "$update_dir/feed"

key_material=$(DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcrun swift -e 'import CryptoKit; import Foundation; let key = Curve25519.Signing.PrivateKey(); print(key.rawRepresentation.base64EncodedString(), key.publicKey.rawRepresentation.base64EncodedString())')
private_key=${key_material%% *}
public_key=${key_material#* }

feed_url="http://127.0.0.1:8765/appcast.xml"
CODEX_LIMITS_QA=1 \
CODEX_LIMITS_VERSION=0.2.6 \
CODEX_LIMITS_BUILD=7 \
CODEX_LIMITS_FEED_URL="$feed_url" \
CODEX_LIMITS_PUBLIC_ED_KEY="$public_key" \
"$project_dir/Scripts/build-app.sh"
ditto "$app_dir" "$update_dir/Codex Limits QA.app"

CODEX_LIMITS_QA=1 \
CODEX_LIMITS_VERSION=0.2.7 \
CODEX_LIMITS_BUILD=8 \
CODEX_LIMITS_FEED_URL="$feed_url" \
CODEX_LIMITS_PUBLIC_ED_KEY="$public_key" \
"$project_dir/Scripts/build-app.sh"
archive="$update_dir/feed/Codex-Limits-QA-0.2.7.zip"
ditto -c -k --sequesterRsrc --keepParent "$app_dir" "$archive"
print -r -- 'Secure in-app updates are ready for local QA.' \
> "$update_dir/feed/Codex-Limits-QA-0.2.7.md"
print -rn -- "$private_key" | \
"$project_dir/.build/artifacts/sparkle/Sparkle/bin/generate_appcast" \
--ed-key-file - \
--download-url-prefix "http://127.0.0.1:8765/" \
--embed-release-notes \
--maximum-deltas 0 \
--critical-update-version '' \
"$update_dir/feed"

nohup python3 -m http.server 8765 --bind 127.0.0.1 \
--directory "$update_dir/feed" \
</dev/null > "$update_dir/server.log" 2>&1 &
print -r -- $! > "$update_dir/server.pid"
for _ in {1..20}; do
curl --silent --fail "$feed_url" >/dev/null && break
sleep 0.1
done
curl --silent --fail "$feed_url" >/dev/null
open "$update_dir/Codex Limits QA.app"
wait "$(<"$update_dir/server.pid")"
;;
*)
print -u2 "Usage: $0 [launch|cleanup]"
print -u2 "Usage: $0 [launch|update|cleanup]"
exit 64
;;
esac
Loading