Add App Store release pipeline, adopt ISC license, add unit test target

- Scripts/release.sh archives and uploads the macOS app via the
  appstore-publish skill's API-driven pipeline (xcodebuild + App Store
  Connect key, no fastlane); ExportOptions-macOS.plist adapts the skill's
  iOS-centric export options for generic/platform=macOS. Scripts/metadata/
  drafts the listing from the README. Canonical asc-*.swift scripts are
  referenced in place from indie-skills, matching Weight/HIIT.
- Replace the all-rights-reserved LICENSE.md with the portfolio-standard
  ISC license (REVIEW2 D2b), preserving the existing copyright year/holder.
- Add an IceGlassTests target (project.yml) with a real smoke test suite
  covering GameState's progression-rank ordering and polling-interval
  mapping — logic MainService relies on to reject stale API regressions.
This commit is contained in:
2026-07-05 10:27:36 -04:00
parent 2f902d844e
commit a755cce3e2
17 changed files with 340 additions and 1 deletions
+16
View File
@@ -0,0 +1,16 @@
# Copy to .env.release (gitignored) in the app's repo root and fill in.
# Used by Scripts/release.sh and the asc-*.swift scripts.
#
# These are account-level — the same Apple developer account and API key
# serve every indie project, so copy this file (as .env.release) from an
# existing sibling project rather than filling it out from scratch.
#
# Apple Developer team that signs the build (same as DEVELOPMENT_TEAM).
APPLE_TEAM_ID=C32Z8JNLG6
# App Store Connect API key (App Store Connect > Users and Access > Integrations > App Store Connect API).
# Create a key with "App Manager" access, download the .p8 ONCE, and store it somewhere safe.
ASC_KEY_ID=XXXXXXXXXX
ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# Absolute path to the downloaded AuthKey_XXXXXXXXXX.p8 file.
ASC_KEY_PATH=/Users/rzen/.appstoreconnect/private_keys/AuthKey_XXXXXXXXXX.p8
+3
View File
@@ -20,3 +20,6 @@ IceGlass.xcodeproj/
# Claude Code local settings
.claude/
# Release credentials (App Store Connect API key env)
.env.release
+65
View File
@@ -0,0 +1,65 @@
//
// GameStateTests.swift
// IceGlassTests
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Testing
@testable import IceGlass
struct GameStateTests {
@Test func decodesFromNHLAPIRawValues() {
#expect(GameState(rawValue: "FUT") == .future)
#expect(GameState(rawValue: "PRE") == .pre)
#expect(GameState(rawValue: "LIVE") == .live)
#expect(GameState(rawValue: "CRIT") == .crit)
#expect(GameState(rawValue: "OVER") == .over)
#expect(GameState(rawValue: "FINAL") == .final_)
#expect(GameState(rawValue: "OFF") == .official)
#expect(GameState(rawValue: "BOGUS") == nil)
}
@Test func isLiveIsOverIsFutureAreMutuallyConsistent() {
let allCases: [GameState] = [.future, .pre, .live, .crit, .over, .final_, .official]
for state in allCases {
// Each state falls into exactly one of the three buckets used to
// drive menu/notification behavior.
let bucketCount = [state.isFuture, state.isLive, state.isOver].filter { $0 }.count
#expect(bucketCount == 1, "\(state) should be in exactly one of future/live/over")
}
#expect(GameState.future.isFuture)
#expect(GameState.pre.isFuture)
#expect(GameState.live.isLive)
#expect(GameState.crit.isLive)
#expect(GameState.over.isOver)
#expect(GameState.final_.isOver)
#expect(GameState.official.isOver)
}
@Test func progressionRankIsMonotonicAlongTheAPILifecycle() {
// FUT PRE LIVE CRIT OVER FINAL OFF must never regress;
// MainService relies on this ordering to reject stale API responses
// that would otherwise flip a finished game back to "-:-".
let ordered: [GameState] = [.future, .pre, .live, .crit, .over, .final_, .official]
let ranks = ordered.map(\.progressionRank)
#expect(ranks == ranks.sorted())
#expect(Set(ranks).count == ranks.count, "ranks must be distinct")
}
@Test func progressionRankOfRawStringMatchesEnumRankAndHandlesUnknown() {
#expect(GameState.progressionRank(of: "LIVE") == GameState.live.progressionRank)
#expect(GameState.progressionRank(of: "OFF") == GameState.official.progressionRank)
#expect(GameState.progressionRank(of: "NOT_A_STATE") == -1)
}
@Test func pollingIntervalMapsEachStateToTheExpectedCadence() {
#expect(GameState.future.pollingInterval == .gameDay)
#expect(GameState.pre.pollingInterval == .preGame)
#expect(GameState.live.pollingInterval == .liveGame)
#expect(GameState.crit.pollingInterval == .liveGame)
#expect(GameState.over.pollingInterval == .everyMinute)
#expect(GameState.final_.pollingInterval == .everyMinute)
#expect(GameState.official.pollingInterval == .idle)
}
}
+7 -1
View File
@@ -1 +1,7 @@
Copyright 2026 Rouslan Zenetl. All Rights Reserved.
**ISC License**
Copyright (c) 2026 Rouslan Zenetl
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Mac App Store distribution for the IceGlass macOS target. -->
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>C32Z8JNLG6</string>
<!-- destination=upload makes -exportArchive push the build straight to
App Store Connect instead of writing a local .pkg. -->
<key>destination</key>
<string>upload</string>
<key>signingStyle</key>
<string>automatic</string>
<key>uploadSymbols</key>
<true/>
<key>manageAppVersionAndBuildNumber</key>
<false/>
</dict>
</plist>
+58
View File
@@ -0,0 +1,58 @@
# App Store metadata
Local source-of-truth for the **IceGlass (macOS)** App Store listing. The
appstore-publish skill's `asc-metadata.swift` (invoked in place from
`../indie-skills`) reads this folder and pushes it to App Store Connect via
the API; only files that are present get pushed. Then `asc-submit.swift`
attaches the build and submits.
```
set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-metadata.swift --bundle dev.rzen.indie.IceGlass --version X.Y.Z --dry-run # preview
set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-metadata.swift --bundle dev.rzen.indie.IceGlass --version X.Y.Z # push
set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-submit.swift --bundle dev.rzen.indie.IceGlass --version X.Y.Z --submit # submit
```
**Note:** this metadata set is for the **macOS** app record only. IceGlass
also ships an iOS companion (`IceGlass-iOS`, bundle ID
`dev.rzen.indie.IceGlass.iOS`) that isn't wired into the release pipeline
yet — when it's ready to publish, give it its own App Store Connect app
record and its own sibling metadata directory (e.g. `Scripts/metadata-ios/`),
since `asc-metadata.swift` currently queries the `IOS` platform filter when
looking up versions, not `MAC_OS`.
## Layout & character limits
| File | Field | Limit | API resource |
|---|---|---|---|
| `app/<locale>/name.txt` | App name | 30 | `appInfoLocalizations` |
| `app/<locale>/subtitle.txt` | Subtitle | 30 | `appInfoLocalizations` |
| `app/<locale>/privacy_url.txt` | Privacy policy URL | — | `appInfoLocalizations` |
| `app/categories.json` | Categories | — | `appInfos` |
| `version/<locale>/description.txt` | Description | 4000 | `appStoreVersionLocalizations` |
| `version/<locale>/keywords.txt` | Keywords (comma-sep) | 100 | `appStoreVersionLocalizations` |
| `version/<locale>/promotional_text.txt` | Promotional text | 170 | `appStoreVersionLocalizations` |
| `version/<locale>/whats_new.txt` | What's New | 4000 | `appStoreVersionLocalizations` |
| `version/<locale>/support_url.txt` | Support URL | — | `appStoreVersionLocalizations` |
| `version/review.json` | Review contact / demo / notes | — | `appStoreReviewDetails` |
| `screenshots/<locale>/<DISPLAY_TYPE>/*.png` | Screenshots | — | `appScreenshotSets` |
`name` + `subtitle` are **app-level**; `description` + `keywords` are
**version-level**. `whats_new.txt` only applies after the first version —
leave it out for 1.0. No `whats_new.txt` or `screenshots/` are checked in
yet — screenshots still need to be captured (see the appstore-publish
skill's "Step 3 — Screenshots" for the simulator-capture recipe; macOS
screenshots are plain `screencapture`/window captures at the App Store's
required pixel sizes, not simulator grabs).
## Screenshot display types (macOS)
Common types: `APP_DESKTOP` (1280×800 or larger, 16:10). One folder per
device class; filenames sort the order. The local set is authoritative —
re-running replaces the remote set for any display type you provide.
## Not handled here (separate resource families)
Age rating (`ageRatingDeclarations`), pricing/availability
(`appPriceSchedules` / `appAvailabilities`), and App Privacy labels
(`appDataUsages`). `asc-submit.swift --submit` (see the appstore-publish
skill) reports any of these that block the submission.
+4
View File
@@ -0,0 +1,4 @@
{
"primary": "SPORTS",
"secondary": null
}
+1
View File
@@ -0,0 +1 @@
IceGlass NHL Game Tracker
@@ -0,0 +1 @@
https://indie.rzen.dev/apps/iceglass/privacy
+1
View File
@@ -0,0 +1 @@
Menu bar scores & schedule
@@ -0,0 +1,25 @@
IceGlass puts NHL game situational awareness right in your Mac's menu bar. No window to open, no tab to switch to — click the shield icon and see exactly where every game stands.
ALWAYS-CURRENT SCOREBOARD
• Shows games from yesterday, today, and tomorrow, grouped by date
• Regular-season rows show the league-wide game number (e.g. "#547 NYR @ WAS")
• Live scores update automatically with a compact "0:2 (FINAL)" style format
• Choose which days you want to see in Display Options
PLAYOFF MODE
• During the playoffs, every round played so far (Round 1 through the current round) gets its own section
• Each series shows its score, the next game number, and the upcoming tip-off time
• Completed series are clearly marked once a team is eliminated
STAY ON TOP OF EVERY GAME
• Goal notifications include the scoring team's logo
• Game-start and game-ended notifications
• Dynamic polling — as often as every 7 seconds while a game is live, backing off automatically when nothing's happening
• Click a game to open it in NHL GameCenter; option-click for NHL Videocast
BUILT FOR THE MENU BAR
• No dock icon, no clutter — IceGlass lives quietly in your menu bar
• Launch at Login so it's always ready
• Refresh Now (⌘R) whenever you want the latest instantly
IceGlass is for anyone who wants to glance at the score without opening a browser tab — season-long, through every round of the playoffs.
@@ -0,0 +1 @@
nhl,hockey,scores,schedule,menu bar,live scores,playoffs,scoreboard,standings,game tracker
@@ -0,0 +1 @@
Live NHL scores in your Mac's menu bar — yesterday, today, and tomorrow's games, full playoff brackets, and goal notifications, at a glance.
@@ -0,0 +1 @@
https://indie.rzen.dev/apps/iceglass/support
+8
View File
@@ -0,0 +1,8 @@
{
"contactFirstName": "Rouslan",
"contactLastName": "Zenetl",
"contactPhone": "+16465840598",
"contactEmail": "[email protected]",
"demoAccountRequired": false,
"notes": "IceGlass is a macOS menu bar app — no window, no dock icon (LSUIElement). After launching, click the NHL shield icon in the menu bar to open the dropdown showing yesterday/today/tomorrow's games. No account or login is required; it reads NHL's public scoreboard API. During the regular season the dropdown groups games by date; during the playoffs it also lists a ROUND section per round played so far. Display Options (which days to show) and Launch at Login are in the app's settings, reachable from the dropdown."
}
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
#
# release.sh — archive IceGlass (macOS menu bar app) and upload to
# App Store Connect.
#
# Usage: Scripts/release.sh
#
# No third-party tooling: pure xcodebuild + the App Store Connect API key.
# Credentials live in .env.release (gitignored); see .env.release.example.
#
# What it does:
# 1. Regenerates the Xcode project with XcodeGen.
# 2. Stamps CFBundleVersion with the git commit count (monotonic build no.)
# via Scripts/update_build_number.sh during the build.
# 3. xcodebuild archive -> .xcarchive (generic/platform=macOS)
# 4. xcodebuild -exportArchive with destination=upload -> App Store Connect.
#
# NOTE: this project also ships an iOS companion (IceGlass-iOS, its own
# bundle ID and — eventually — its own App Store Connect app record). This
# script only releases the macOS app. When the iOS companion is ready to
# publish, copy this file to release-ios.sh, swap PROJECT/SCHEME/BUNDLE_ID
# for the iOS target, destination to "generic/platform=iOS", and point
# -exportOptionsPlist at an ExportOptions-iOS.plist (see the appstore-publish
# skill's template) — plus a sibling Scripts/metadata/ set for its own listing.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT"
PROJECT="IceGlass.xcodeproj"
SCHEME="IceGlass"
BUILD_DIR="$ROOT/build"
# ---- credentials ------------------------------------------------------------
ENV_FILE="$ROOT/.env.release"
if [ -f "$ENV_FILE" ]; then
set -a; . "$ENV_FILE"; set +a
fi
: "${APPLE_TEAM_ID:?Set APPLE_TEAM_ID (copy .env.release.example -> .env.release)}"
: "${ASC_KEY_ID:?Set ASC_KEY_ID in .env.release}"
: "${ASC_ISSUER_ID:?Set ASC_ISSUER_ID in .env.release}"
: "${ASC_KEY_PATH:?Set ASC_KEY_PATH in .env.release}"
[ -f "$ASC_KEY_PATH" ] || { echo "❌ API key not found at: $ASC_KEY_PATH"; exit 1; }
AUTH=(
-authenticationKeyPath "$ASC_KEY_PATH"
-authenticationKeyID "$ASC_KEY_ID"
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
-allowProvisioningUpdates
)
# ---- regenerate project -----------------------------------------------------
if command -v xcodegen >/dev/null 2>&1; then
echo "🧩 Generating $PROJECT ..."
xcodegen generate
elif [ ! -d "$PROJECT" ]; then
echo "$PROJECT missing and xcodegen not installed."; exit 1
fi
# ---- versioning -------------------------------------------------------------
# CFBundleVersion is stamped during the build by Scripts/update_build_number.sh
# (raw commit count). The marketing version lives in project.yml's
# MARKETING_VERSION setting for the IceGlass target (Info.plist references it
# via $(MARKETING_VERSION), so there's no literal to PlistBuddy-read).
BUILD_NUMBER="$(git rev-list HEAD --count)"
MARKETING_VERSION="$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*"([^"]+)".*/\1/')"
echo "📦 Version $MARKETING_VERSION — build $BUILD_NUMBER"
mkdir -p "$BUILD_DIR"
ARCHIVE_PATH="$BUILD_DIR/$SCHEME.xcarchive"
EXPORT_PATH="$BUILD_DIR/$SCHEME-export"
rm -rf "$ARCHIVE_PATH" "$EXPORT_PATH"
echo ""
echo "🛠 Archiving $SCHEME (macOS) ..."
xcodebuild archive \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Release \
-destination "generic/platform=macOS" \
-archivePath "$ARCHIVE_PATH" \
CODE_SIGN_STYLE=Automatic \
DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \
"${AUTH[@]}"
echo "🚀 Exporting + uploading to App Store Connect ..."
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist "$SCRIPT_DIR/ExportOptions-macOS.plist" \
"${AUTH[@]}"
echo "✅ IceGlass uploaded (build $BUILD_NUMBER)."
echo ""
echo "🎉 Done. The build appears in App Store Connect > TestFlight after processing (~515 min)."
echo ""
echo "➡️ Push metadata, then stage/submit for review with the canonical scripts"
echo " (apps sit as siblings of indie-skills under ~/Documents/indie; --bundle"
echo " and --version are required flags — there are no per-app defaults):"
echo ""
echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-metadata.swift --bundle dev.rzen.indie.IceGlass --version $MARKETING_VERSION --dry-run"
echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-metadata.swift --bundle dev.rzen.indie.IceGlass --version $MARKETING_VERSION"
echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-submit.swift --bundle dev.rzen.indie.IceGlass --version $MARKETING_VERSION # stage only"
echo " set -a; source .env.release; set +a; swift ../indie-skills/skills/appstore-publish/scripts/asc-submit.swift --bundle dev.rzen.indie.IceGlass --version $MARKETING_VERSION --submit # stage + submit"
+20
View File
@@ -59,6 +59,22 @@ targets:
com.apple.security.app-sandbox: true
com.apple.security.network.client: true
IceGlassTests:
type: bundle.unit-test
platform: macOS
sources:
- IceGlassTests
dependencies:
- target: IceGlass
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.rzen.indie.IceGlassTests
DEVELOPMENT_TEAM: C32Z8JNLG6
SWIFT_VERSION: "6.0"
MACOSX_DEPLOYMENT_TARGET: "13.0"
SWIFT_STRICT_CONCURRENCY: complete
GENERATE_INFOPLIST_FILE: true
IceGlass-iOS:
type: application
platform: iOS
@@ -107,6 +123,10 @@ schemes:
config: Debug
archive:
config: Release
test:
config: Debug
targets:
- IceGlassTests
IceGlass-iOS:
build: