diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 56cc925..d8e623a 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,18 @@ +{"_type":"issue","id":"risus-cli-7n5","title":"Fix macOS zip: unzip into named directory not dist/","description":"When a user unzips risus-macos-arm64.zip they get a dist/ directory — this is a CI build artifact path leaking into the release. Fix: stage the binary into /tmp/risus-macos-arm64/ before running ditto, so the zip contains risus-macos-arm64/risus-macos-arm64 and extracts into a sensibly named directory.\n\nChange is one step in .github/workflows/release.yml (Package signed binary). See specs/006-zip-directory-structure/ for spec, plan, and tasks.","status":"closed","priority":2,"issue_type":"feature","assignee":"galadriel","owner":"galadriel@example.com","created_at":"2026-05-04T07:00:22Z","created_by":"galadriel","updated_at":"2026-05-04T12:32:59Z","started_at":"2026-05-04T12:31:07Z","closed_at":"2026-05-04T12:32:59Z","close_reason":"Stage binary in /tmp/risus-macos-arm64/ before ditto; zip now extracts to risus-macos-arm64/ not dist/","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"risus-cli-70j","title":"Conditional signing: gate macOS steps on APPLE_CERTIFICATE secret","description":"Gate all macOS signing steps in release.yml on env.APPLE_CERTIFICATE != '' so branches/PRs without secrets produce an unsigned binary instead of failing. Add an assert step on tag pushes that fails loudly if secrets are missing, preventing accidental unsigned release artifact.\n\nChanges to .github/workflows/release.yml:\n- All macOS signing steps (import-codesign-certs, sign, package, notarize, clean up, verify): add env.APPLE_CERTIFICATE != '' to the if condition\n- Add 'Assert signing secrets' step with if: runner.os == 'macOS' \u0026\u0026 github.ref_type == 'tag' that checks APPLE_CERTIFICATE env var and exits 1 with clear error if empty\n\nSpec: specs/005-macos-signed-release/tasks.md T015","status":"closed","priority":2,"issue_type":"task","assignee":"galadriel","owner":"galadriel@example.com","created_at":"2026-05-04T06:31:24Z","created_by":"galadriel","updated_at":"2026-05-04T12:32:57Z","started_at":"2026-05-04T12:31:05Z","closed_at":"2026-05-04T12:32:57Z","close_reason":"Gated all macOS signing steps on env.APPLE_CERTIFICATE != ''; added Assert step for tag pushes; unsigned fallback for branch builds","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"risus-cli-q0d","title":"T009: Add post-notarization codesign/spctl verification step","description":"speckit:005-macos-signed-release | T009 | Add post-notarization verification step (if: runner.os == macOS) in .github/workflows/release.yml running codesign --verify and spctl --assess","status":"closed","priority":2,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T19:21:13Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:09Z","closed_at":"2026-05-03T19:26:09Z","close_reason":"Closed","dependencies":[{"issue_id":"risus-cli-q0d","depends_on_id":"risus-cli-77n","type":"blocks","created_at":"2026-05-03T21:22:15Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"risus-cli-sa3","title":"T008: Update macOS artifact upload to use zip in release.yml","description":"speckit:005-macos-signed-release | T008 | Update macOS artifact upload step in .github/workflows/release.yml to upload risus-macos-arm64.zip and .sha256","status":"closed","priority":2,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T19:21:12Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:09Z","closed_at":"2026-05-03T19:26:09Z","close_reason":"Closed","dependencies":[{"issue_id":"risus-cli-sa3","depends_on_id":"risus-cli-457","type":"blocks","created_at":"2026-05-03T21:22:20Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"risus-cli-457","title":"T007: Update checksum step to hash zip on macOS in release.yml","description":"speckit:005-macos-signed-release | T007 | Update Compute checksum step in .github/workflows/release.yml to hash risus-macos-arm64.zip on macOS","status":"closed","priority":2,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T19:21:10Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:08Z","closed_at":"2026-05-03T19:26:08Z","close_reason":"Closed","dependencies":[{"issue_id":"risus-cli-457","depends_on_id":"risus-cli-77n","type":"blocks","created_at":"2026-05-03T21:22:14Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"risus-cli-77n","title":"T006: Add xcrun notarytool submit step to release.yml","description":"speckit:005-macos-signed-release | T006 | Add xcrun notarytool submit --wait step (if: runner.os == macOS, timeout-minutes: 15) in .github/workflows/release.yml","status":"closed","priority":2,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T19:21:09Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:08Z","closed_at":"2026-05-03T19:26:08Z","close_reason":"Closed","dependencies":[{"issue_id":"risus-cli-77n","depends_on_id":"risus-cli-7bc","type":"blocks","created_at":"2026-05-03T21:22:13Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"risus-cli-7bc","title":"T005: Add ditto zip step to release.yml after signing","description":"speckit:005-macos-signed-release | T005 | Add ditto -c -k --keepParent step (if: runner.os == macOS) after signing in .github/workflows/release.yml","status":"closed","priority":2,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T19:21:08Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:07Z","closed_at":"2026-05-03T19:26:07Z","close_reason":"Closed","dependencies":[{"issue_id":"risus-cli-7bc","depends_on_id":"risus-cli-161","type":"blocks","created_at":"2026-05-03T21:22:08Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"risus-cli-161","title":"T004: Add codesign step to release.yml after binary rename","description":"speckit:005-macos-signed-release | T004 | Add codesign --force --verbose --timestamp --sign step (if: runner.os == macOS) after binary rename in .github/workflows/release.yml","status":"closed","priority":2,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T19:21:06Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:07Z","closed_at":"2026-05-03T19:26:07Z","close_reason":"Closed","dependencies":[{"issue_id":"risus-cli-161","depends_on_id":"risus-cli-p7l","type":"blocks","created_at":"2026-05-03T21:22:07Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"risus-cli-p7l","title":"T002: Add apple-actions/import-codesign-certs@v7 to release.yml","description":"speckit:005-macos-signed-release | T002 | Add apple-actions/import-codesign-certs@v7 step (if: runner.os == macOS) to macOS matrix job in .github/workflows/release.yml","status":"closed","priority":2,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T19:21:05Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:06Z","closed_at":"2026-05-03T19:26:06Z","close_reason":"Closed","dependencies":[{"issue_id":"risus-cli-p7l","depends_on_id":"risus-cli-2my","type":"blocks","created_at":"2026-05-03T21:21:59Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"risus-cli-6rk","title":"T014: Run manual end-to-end verification per quickstart.md","description":"speckit:005-macos-signed-release | T014 | Run manual end-to-end verification per specs/005-macos-signed-release/quickstart.md against first notarized release artifact","status":"open","priority":2,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T19:20:59Z","created_by":"galadriel","updated_at":"2026-05-03T19:20:59Z","dependencies":[{"issue_id":"risus-cli-6rk","depends_on_id":"risus-cli-t58","type":"blocks","created_at":"2026-05-03T21:22:33Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"risus-cli-t58","title":"T013: Update macOS job name to Build \u0026 Sign in release.yml","description":"speckit:005-macos-signed-release | T013 | Update macOS entry in release workflow job name from Build (macos-latest) to Build \u0026 Sign (macos-latest)","status":"closed","priority":2,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T19:20:58Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:11Z","closed_at":"2026-05-03T19:26:11Z","close_reason":"Closed","dependencies":[{"issue_id":"risus-cli-t58","depends_on_id":"risus-cli-0op","type":"blocks","created_at":"2026-05-03T21:22:24Z","created_by":"galadriel","metadata":"{}"},{"issue_id":"risus-cli-t58","depends_on_id":"risus-cli-92w","type":"blocks","created_at":"2026-05-03T21:22:26Z","created_by":"galadriel","metadata":"{}"},{"issue_id":"risus-cli-t58","depends_on_id":"risus-cli-hb2","type":"blocks","created_at":"2026-05-03T21:22:25Z","created_by":"galadriel","metadata":"{}"},{"issue_id":"risus-cli-t58","depends_on_id":"risus-cli-q0d","type":"blocks","created_at":"2026-05-03T21:22:23Z","created_by":"galadriel","metadata":"{}"},{"issue_id":"risus-cli-t58","depends_on_id":"risus-cli-sa3","type":"blocks","created_at":"2026-05-03T21:22:21Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":5,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"risus-cli-92w","title":"T012: Add pre-tag secrets check to AGENTS.md Release Checklist","description":"speckit:005-macos-signed-release | T012 | Add pre-tag check to Release Checklist in AGENTS.md: verify all 6 Apple signing secrets configured","status":"closed","priority":2,"issue_type":"task","assignee":"galadriel","owner":"galadriel@example.com","created_at":"2026-05-03T19:20:57Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:11Z","started_at":"2026-05-03T19:24:04Z","closed_at":"2026-05-03T19:26:11Z","close_reason":"Closed","dependencies":[{"issue_id":"risus-cli-92w","depends_on_id":"risus-cli-2my","type":"blocks","created_at":"2026-05-03T21:22:02Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"risus-cli-hb2","title":"T011: Update build.yml on.push.branches for feature branch CI","description":"speckit:005-macos-signed-release | T011 | Update on.push.branches in .github/workflows/build.yml to add 005-macos-signed-release","status":"closed","priority":2,"issue_type":"task","assignee":"galadriel","owner":"galadriel@example.com","created_at":"2026-05-03T19:20:55Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:10Z","started_at":"2026-05-03T19:24:03Z","closed_at":"2026-05-03T19:26:10Z","close_reason":"Closed","dependencies":[{"issue_id":"risus-cli-hb2","depends_on_id":"risus-cli-2my","type":"blocks","created_at":"2026-05-03T21:22:01Z","created_by":"galadriel","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"risus-cli-0op","title":"T010: Add Signing Setup subsection to AGENTS.md Release Checklist","description":"speckit:005-macos-signed-release | T010 | Add Signing Setup subsection to Release Checklist in AGENTS.md documenting 6 required GitHub Actions secrets","status":"closed","priority":2,"issue_type":"task","assignee":"galadriel","owner":"galadriel@example.com","created_at":"2026-05-03T19:20:54Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:10Z","started_at":"2026-05-03T19:24:03Z","closed_at":"2026-05-03T19:26:10Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"risus-cli-2my","title":"T001: Create build/entitlements.plist with 4 Hardened Runtime entitlements","description":"speckit:005-macos-signed-release | T001 | Create build/entitlements.plist with 4 entitlements required for PyInstaller Python runtime under Hardened Runtime","status":"closed","priority":2,"issue_type":"task","assignee":"galadriel","owner":"galadriel@example.com","created_at":"2026-05-03T19:20:52Z","created_by":"galadriel","updated_at":"2026-05-03T19:26:06Z","started_at":"2026-05-03T19:24:01Z","closed_at":"2026-05-03T19:26:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"risus-cli-znu","title":"Fix silent connection failure in WSClient.start()","description":"WSClient.start() treats a 'disconnected' frame as a successful connection. When the WebSocket handshake fails (SSL, DNS, network), _async_run swallows the exception and puts a 'disconnected' frame in the inbox. start() reads it, doesn't recognise it as failure, puts it back and returns — showing the user a working menu that is not actually connected to any server.","acceptance_criteria":"1. When connection fails for any reason (SSL, DNS, network), the program exits immediately with a clear error message instead of showing the menu. 2. Error message reads: 'Connection to {server} failed — check address, network, and that the server is running.' 3. WSClient emits a 'connected' sentinel frame after successful WebSocket handshake, before reader starts. 4. start() raises TimeoutError immediately on 'disconnected' frame rather than waiting the full timeout. 5. Existing tests pass. 6. Manual test: wrong server address → immediate exit with correct message.","status":"closed","priority":2,"issue_type":"bug","owner":"galadriel@example.com","created_at":"2026-05-03T17:23:42Z","created_by":"galadriel","updated_at":"2026-05-03T17:26:45Z","closed_at":"2026-05-03T17:26:45Z","close_reason":"connected sentinel + immediate TimeoutError on disconnected + updated error message; 77 unit tests pass","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"risus-cli-cn0","title":"T026: Validate quickstart.md local dev scenario","description":"speckit:004-secure-session | T026 | Validate quickstart.md scenario with --token dev-token-for-testing uses ws://","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-03T07:33:54Z","created_by":"Eudicy","updated_at":"2026-05-03T08:07:52Z","started_at":"2026-05-03T08:07:10Z","closed_at":"2026-05-03T08:07:52Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"risus-cli-ddm","title":"T025: Verify server logs show reason but not token","description":"speckit:004-secure-session | T025 | Verify server logs show reason=token_absent or reason=token_mismatch but never token value","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-03T07:33:53Z","created_by":"Eudicy","updated_at":"2026-05-03T08:07:52Z","started_at":"2026-05-03T08:07:10Z","closed_at":"2026-05-03T08:07:52Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -25,26 +40,7 @@ {"_type":"issue","id":"risus-cli-2es","title":"T003: Add AuthError class to client/ws_client.py","description":"speckit:004-secure-session | T003 | Add class AuthError(Exception): pass to client/ws_client.py","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-03T07:33:25Z","created_by":"Eudicy","updated_at":"2026-05-03T07:38:16Z","started_at":"2026-05-03T07:36:21Z","closed_at":"2026-05-03T07:38:16Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"risus-cli-5nk","title":"T002: Create tests/e2e/test_token_auth.py","description":"speckit:004-secure-session | T002 | Create tests/e2e/test_token_auth.py with module-level docstring and empty test skeleton","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-03T07:33:24Z","created_by":"Eudicy","updated_at":"2026-05-03T07:38:15Z","started_at":"2026-05-03T07:36:20Z","closed_at":"2026-05-03T07:38:15Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"risus-cli-6dr","title":"T001: Create tests/unit/test_token_auth.py","description":"speckit:004-secure-session | T001 | Create tests/unit/test_token_auth.py with module-level docstring and empty test skeleton","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-03T07:33:21Z","created_by":"Eudicy","updated_at":"2026-05-03T07:38:15Z","started_at":"2026-05-03T07:36:18Z","closed_at":"2026-05-03T07:38:15Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-rfk","title":"T020: Run ruff check on risus.py and client/config.py","description":"speckit:003-standalone-client | T020 | Run ruff check on risus.py and client/config.py; fix any issues","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:22Z","created_by":"Eudicy","updated_at":"2026-05-02T19:31:44Z","started_at":"2026-05-02T19:31:03Z","closed_at":"2026-05-02T19:31:44Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-44q","title":"T019: End-to-end smoke test: build binary, copy to temp dir","description":"speckit:003-standalone-client | T019 | Run end-to-end smoke test: build binary, copy to temp dir (no venv)","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:21Z","created_by":"Eudicy","updated_at":"2026-05-02T19:34:44Z","started_at":"2026-05-02T19:31:52Z","closed_at":"2026-05-02T19:34:44Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-1dt","title":"T018: Run e2e tests with podman against live server","description":"speckit:003-standalone-client | T018 | Run CONTAINER_ENGINE=podman pytest tests/e2e -m e2e -q","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:20Z","created_by":"Eudicy","updated_at":"2026-05-02T19:34:43Z","started_at":"2026-05-02T19:31:51Z","closed_at":"2026-05-02T19:34:43Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-q81","title":"T017: Run pytest tests/unit -q and confirm new tests pass","description":"speckit:003-standalone-client | T017 | Run pytest tests/unit -q and confirm all new tests pass","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:19Z","created_by":"Eudicy","updated_at":"2026-05-02T19:31:44Z","started_at":"2026-05-02T19:31:02Z","closed_at":"2026-05-02T19:31:44Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-0x7","title":"T016: Verify risus.cfg and dist/ absent from git status","description":"speckit:003-standalone-client | T016 | Verify risus.cfg and dist/ absent from git status","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:17Z","created_by":"Eudicy","updated_at":"2026-05-02T19:31:43Z","started_at":"2026-05-02T19:31:01Z","closed_at":"2026-05-02T19:31:43Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-zkt","title":"T015: Update README.md — add Playing the Game section","description":"speckit:003-standalone-client | T015 | [US3] Update project root README.md — add Playing the Game section","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:12Z","created_by":"Eudicy","updated_at":"2026-05-02T19:30:59Z","started_at":"2026-05-02T19:24:49Z","closed_at":"2026-05-02T19:30:59Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-1r1","title":"T014: Create PLAYER.md at project root for end users","description":"speckit:003-standalone-client | T014 | [US3] Create PLAYER.md at project root — adapt content from spec","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:11Z","created_by":"Eudicy","updated_at":"2026-05-02T19:30:59Z","started_at":"2026-05-02T19:24:48Z","closed_at":"2026-05-02T19:30:59Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-ral","title":"T013: Write developer build instructions in BUILDING.md","description":"speckit:003-standalone-client | T013 | [US2] Write complete developer build instructions in BUILDING.md","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:09Z","created_by":"Eudicy","updated_at":"2026-05-02T19:30:58Z","started_at":"2026-05-02T19:24:47Z","closed_at":"2026-05-02T19:30:58Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-r9k","title":"T012: Create .github/workflows/release.yml release workflow","description":"speckit:003-standalone-client | T012 | [US2] Create .github/workflows/release.yml — trigger on push tag","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:08Z","created_by":"Eudicy","updated_at":"2026-05-02T19:30:58Z","started_at":"2026-05-02T19:24:46Z","closed_at":"2026-05-02T19:30:58Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-w79","title":"T011: Create .github/workflows/build.yml CI workflow","description":"speckit:003-standalone-client | T011 | [US2] Create .github/workflows/build.yml — on push/PR to main","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:07Z","created_by":"Eudicy","updated_at":"2026-05-02T19:30:57Z","started_at":"2026-05-02T19:24:45Z","closed_at":"2026-05-02T19:30:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-yum","title":"T010: Improve server-unreachable error message in risus.py","description":"speckit:003-standalone-client | T010 | [US1] Improve server-unreachable error message in risus.py","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:03Z","created_by":"Eudicy","updated_at":"2026-05-02T19:24:44Z","started_at":"2026-05-02T19:23:45Z","closed_at":"2026-05-02T19:24:44Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-0p8","title":"T009: Create build/risus.spec PyInstaller spec file","description":"speckit:003-standalone-client | T009 | [US1] Create build/risus.spec — PyInstaller spec file","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:02Z","created_by":"Eudicy","updated_at":"2026-05-02T19:24:43Z","started_at":"2026-05-02T19:23:45Z","closed_at":"2026-05-02T19:24:43Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-7eo","title":"T008: Unit tests for startup prompt logic in risus.py","description":"speckit:003-standalone-client | T008 | [US1] Unit tests for startup prompt logic in risus.py","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:19:01Z","created_by":"Eudicy","updated_at":"2026-05-02T19:24:43Z","started_at":"2026-05-02T19:23:43Z","closed_at":"2026-05-02T19:24:43Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-k17","title":"T007: Update risus.py — replace positional sys.argv access","description":"speckit:003-standalone-client | T007 | [US1] Update risus.py — replace positional sys.argv access","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:18:59Z","created_by":"Eudicy","updated_at":"2026-05-02T19:24:42Z","started_at":"2026-05-02T19:22:24Z","closed_at":"2026-05-02T19:24:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-z9r","title":"T006: Unit tests for client/config.py","description":"speckit:003-standalone-client | T006 | Unit tests for client/config.py in tests/unit/","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:18:58Z","created_by":"Eudicy","updated_at":"2026-05-02T19:22:22Z","started_at":"2026-05-02T19:21:24Z","closed_at":"2026-05-02T19:22:22Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-ojy","title":"T005: Implement client/config.py with two public functions","description":"speckit:003-standalone-client | T005 | Implement client/config.py with two public functions","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:18:53Z","created_by":"Eudicy","updated_at":"2026-05-02T19:22:22Z","started_at":"2026-05-02T19:21:24Z","closed_at":"2026-05-02T19:22:22Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-d9m","title":"T004: Add pyinstaller to dev optional-dependencies in pyproject","description":"speckit:003-standalone-client | T004 | Add pyinstaller to [project.optional-dependencies] dev","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:18:52Z","created_by":"Eudicy","updated_at":"2026-05-02T19:22:22Z","started_at":"2026-05-02T19:21:06Z","closed_at":"2026-05-02T19:22:22Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-838","title":"T003: Create risus.cfg.example at project root","description":"speckit:003-standalone-client | T003 | Create risus.cfg.example at project root per spec","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:18:51Z","created_by":"Eudicy","updated_at":"2026-05-02T19:22:21Z","started_at":"2026-05-02T19:21:05Z","closed_at":"2026-05-02T19:22:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-b27","title":"T002: Create build/ directory with placeholder build/README.md","description":"speckit:003-standalone-client | T002 | Create build/ directory with placeholder build/README.md","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:18:49Z","created_by":"Eudicy","updated_at":"2026-05-02T19:22:21Z","started_at":"2026-05-02T19:21:04Z","closed_at":"2026-05-02T19:22:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"risus-cli-8jd","title":"T001: Add dist/, build/risus/, risus.cfg entries to .gitignore","description":"speckit:003-standalone-client | T001 | Add dist/, build/risus/, and risus.cfg entries to .gitignore","status":"closed","priority":2,"issue_type":"task","assignee":"Eudicy","owner":"eudicy@boos.systems","created_at":"2026-05-02T19:18:47Z","created_by":"Eudicy","updated_at":"2026-05-02T19:22:20Z","started_at":"2026-05-02T19:21:02Z","closed_at":"2026-05-02T19:22:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"risus-cli-255","title":"Update quickstart.md with cert export corrections","description":"Update specs/005-macos-signed-release/quickstart.md with corrections from setup walkthrough session.\n\nCorrections to apply:\n1. Keychain Access shows Developer ID cert under 'Certificates' tab, NOT 'My Certificates' (two nearly identical tabs exist — common confusion point)\n2. After adding APPLE_CERTIFICATE secret to GitHub, remind user to delete the local .p12 file — it is NOT encrypted at rest and contains the private key\n\nSee AGENTS.md for session workflow.","notes":"Also add to quickstart: APPLE_SIGNING_IDENTITY secret = full cert identity string (format: 'Developer ID Application: Name (TeamID)'). Verify exact string via: security find-identity -v -p codesigning. Do NOT use APPLE_TEAM_ID for --sign flag — team ID alone is not a valid identity.","status":"closed","priority":3,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-04T03:36:39Z","created_by":"galadriel","updated_at":"2026-05-04T04:47:54Z","closed_at":"2026-05-04T04:47:54Z","close_reason":"All corrections applied: quickstart rewritten with Xcode export method, APPLE_SIGNING_IDENTITY, Request Access gate, .p12 delete warning, version bump step, spctl note. PLAYER.md, AGENTS.md, spec003 quickstart all updated for notarized binary.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"risus-cli-m6m","title":"Bump version to 1.0.3 after fixing silent connection failure","description":"After risus-cli-znu (silent connection failure fix) and risus-cli-7yn (troubleshooting docs) are merged, bump pyproject.toml version to 1.0.3 and tag a release so users get updated binaries.","status":"closed","priority":3,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T17:24:57Z","created_by":"galadriel","updated_at":"2026-05-03T17:27:28Z","closed_at":"2026-05-03T17:27:28Z","close_reason":"pyproject.toml bumped to 1.0.3; 77 unit tests pass","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"risus-cli-7yn","title":"Add troubleshooting note for silent connection failure symptom","description":"Users who download the binary may see the menu appear instantly with no players and no server delay, which looks like a successful launch but means the client is not actually connected to the server. This symptom is hard to self-diagnose without documentation. Add a troubleshooting section to PLAYER.md or README.md describing: symptom (menu instant, no players, no delay), what it means (connection failed silently), and how to verify/fix (check risus.cfg is next to binary, check server address has no port if using TLS).","status":"closed","priority":3,"issue_type":"task","owner":"galadriel@example.com","created_at":"2026-05-03T17:21:14Z","created_by":"galadriel","updated_at":"2026-05-03T17:27:09Z","closed_at":"2026-05-03T17:27:09Z","close_reason":"Added troubleshooting row to PLAYER.md: symptom, cause, and two actionable fixes","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"risus-cli-5af","title":"Clean up docs/features/secure-session/ — verify content covered by specs/004-secure-session/ then delete","description":"docs/features/secure-session/ contains prd.md and concept.md. Before deleting, verify all relevant details are present in specs/004-secure-session/ (spec.md, plan.md, research.md, data-model.md, contracts/). If anything is missing, move it first. Then delete the docs/features/secure-session/ directory.","status":"open","priority":3,"issue_type":"task","owner":"eudicy@boos.systems","created_at":"2026-05-03T12:34:13Z","created_by":"Eudicy","updated_at":"2026-05-03T12:34:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5c3842..95c5e19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,9 +2,9 @@ name: Build on: push: - branches: [main, 003-standalone-client] + branches: [main, 003-standalone-client, 005-macos-signed-release] pull_request: - branches: [main, 003-standalone-client] + branches: [main, 003-standalone-client, 005-macos-signed-release] jobs: build: @@ -15,9 +15,9 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" @@ -39,7 +39,7 @@ jobs: shell: pwsh - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: risus-${{ matrix.os }} path: dist/risus* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13a9cf4..58b7a14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,25 +10,25 @@ permissions: jobs: build: - name: Build (${{ matrix.os }}) + name: Build & Sign (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: matrix: include: - os: ubuntu-latest artifact: risus-linux-x86_64 - binary: dist/risus - os: macos-latest artifact: risus-macos-arm64 - binary: dist/risus - os: windows-latest artifact: risus-windows-x86_64.exe - binary: dist/risus.exe + + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "3.12" @@ -47,8 +47,72 @@ jobs: run: Rename-Item dist\risus.exe ${{ matrix.artifact }} shell: pwsh - - name: Compute checksum (Unix) - if: runner.os != 'Windows' + - name: Assert signing secrets + if: runner.os == 'macOS' && github.ref_type == 'tag' + run: | + if [ -z "$APPLE_CERTIFICATE" ]; then + echo "ERROR: APPLE_CERTIFICATE secret is not configured. Refusing to publish an unsigned release artifact." + echo "Configure the 6 Apple signing secrets in repository Settings → Secrets before pushing a release tag." + echo "See specs/005-macos-signed-release/quickstart.md for setup instructions." + exit 1 + fi + + - name: Import signing certificate + if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' + uses: apple-actions/import-codesign-certs@v7 + with: + p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} + p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + - name: Sign binary + if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' + run: | + codesign --force --verbose --timestamp \ + --sign "$APPLE_SIGNING_IDENTITY" \ + --options=runtime --no-strict \ + --entitlements build/entitlements.plist \ + dist/risus-macos-arm64 + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + + - name: Package signed binary + if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' + run: | + mkdir -p /tmp/risus-macos-arm64 + cp dist/risus-macos-arm64 /tmp/risus-macos-arm64/ + ditto -c -k --keepParent /tmp/risus-macos-arm64 dist/risus-macos-arm64.zip + + - name: Notarize + if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' + timeout-minutes: 15 + run: | + echo "$APPLE_API_KEY_CONTENT" | base64 --decode > /tmp/apple_api_key.p8 + xcrun notarytool submit dist/risus-macos-arm64.zip \ + --wait \ + --key /tmp/apple_api_key.p8 \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" + env: + APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + + - name: Clean up API key + if: always() && runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' + run: rm -f /tmp/apple_api_key.p8 + + - name: Verify signature + if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' + run: codesign --verify --deep --strict --verbose=2 dist/risus-macos-arm64 + + - name: Compute checksum (macOS, signed) + if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' + run: | + cd dist + sha256sum risus-macos-arm64.zip > risus-macos-arm64.zip.sha256 + + - name: Compute checksum (non-signed) + if: runner.os != 'Windows' && (runner.os != 'macOS' || env.APPLE_CERTIFICATE == '') run: | cd dist sha256sum ${{ matrix.artifact }} > ${{ matrix.artifact }}.sha256 @@ -60,8 +124,18 @@ jobs: "$hash ${{ matrix.artifact }}" | Out-File -FilePath dist\${{ matrix.artifact }}.sha256 -Encoding ascii shell: pwsh + - name: Upload artifact (macOS, signed) + if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.artifact }} + path: | + dist/risus-macos-arm64.zip + dist/risus-macos-arm64.zip.sha256 + - name: Upload artifact - uses: actions/upload-artifact@v4 + if: runner.os != 'macOS' || env.APPLE_CERTIFICATE == '' + uses: actions/upload-artifact@v7 with: name: ${{ matrix.artifact }} path: | @@ -73,16 +147,16 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: release-assets merge-multiple: true - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: | release-assets/* diff --git a/.gitignore b/.gitignore index cedfdb6..2b91419 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# MCP secrets +.mcp.json + # Oh My Claude Code state - block all, then carve out what to share !.omc/ .omc/* diff --git a/.omc/project-memory.json b/.omc/project-memory.json index 8052137..fa6c3a9 100644 --- a/.omc/project-memory.json +++ b/.omc/project-memory.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "lastScanned": 1777804471603, + "lastScanned": 1777897422303, "projectRoot": "/home/galadriel/Documents/Cline/risus-cli", "techStack": { "languages": [ @@ -30,7 +30,7 @@ }, "build": { "buildCommand": null, - "testCommand": ".venv/bin/pytest tests/unit -q 2>&1 && .venv/bin/ruff check risus.py client/ws_client.py 2>&1", + "testCommand": "pytest", "lintCommand": "ruff check", "devCommand": null, "scripts": {} @@ -59,7 +59,7 @@ "path": "__pycache__", "purpose": null, "fileCount": 1, - "lastAccessed": 1777804471593, + "lastAccessed": 1777897422278, "keyFiles": [ "risus.cpython-312.pyc" ] @@ -67,10 +67,11 @@ "build": { "path": "build", "purpose": "Build output", - "fileCount": 2, - "lastAccessed": 1777804471596, + "fileCount": 3, + "lastAccessed": 1777897422295, "keyFiles": [ "README.md", + "entitlements.plist", "risus.spec" ] }, @@ -78,7 +79,7 @@ "path": "client", "purpose": null, "fileCount": 4, - "lastAccessed": 1777804471600, + "lastAccessed": 1777897422300, "keyFiles": [ "__init__.py", "config.py", @@ -90,7 +91,7 @@ "path": "dist", "purpose": "Distribution/build output", "fileCount": 1, - "lastAccessed": 1777804471601, + "lastAccessed": 1777897422301, "keyFiles": [ "risus" ] @@ -99,7 +100,7 @@ "path": "docker", "purpose": null, "fileCount": 1, - "lastAccessed": 1777804471601, + "lastAccessed": 1777897422301, "keyFiles": [ "server.Dockerfile" ] @@ -108,14 +109,14 @@ "path": "docs", "purpose": "Documentation", "fileCount": 0, - "lastAccessed": 1777804471601, + "lastAccessed": 1777897422301, "keyFiles": [] }, "risus_cli.egg-info": { "path": "risus_cli.egg-info", "purpose": null, "fileCount": 5, - "lastAccessed": 1777804471601, + "lastAccessed": 1777897422301, "keyFiles": [ "PKG-INFO", "SOURCES.txt", @@ -128,7 +129,7 @@ "path": "server", "purpose": null, "fileCount": 9, - "lastAccessed": 1777804471601, + "lastAccessed": 1777897422301, "keyFiles": [ "__init__.py", "app.py", @@ -141,14 +142,14 @@ "path": "specs", "purpose": null, "fileCount": 0, - "lastAccessed": 1777804471601, + "lastAccessed": 1777897422302, "keyFiles": [] }, "tests": { "path": "tests", "purpose": "Test files", "fileCount": 1, - "lastAccessed": 1777804471602, + "lastAccessed": 1777897422302, "keyFiles": [ "__init__.py" ] @@ -169,26 +170,26 @@ }, { "path": "AGENTS.md", - "accessCount": 22, - "lastAccessed": 1777812713591, + "accessCount": 29, + "lastAccessed": 1777869979110, "type": "file" }, { "path": ".specify/memory/constitution.md", - "accessCount": 21, - "lastAccessed": 1777812763030, + "accessCount": 23, + "lastAccessed": 1777831335578, "type": "file" }, { - "path": "specs/003-standalone-client/spec.md", - "accessCount": 20, - "lastAccessed": 1777749597169, + "path": "client/ws_client.py", + "accessCount": 21, + "lastAccessed": 1777829969586, "type": "file" }, { - "path": "client/ws_client.py", + "path": "specs/003-standalone-client/spec.md", "accessCount": 20, - "lastAccessed": 1777829327554, + "lastAccessed": 1777749597169, "type": "file" }, { @@ -215,12 +216,24 @@ "lastAccessed": 1777793571549, "type": "file" }, + { + "path": "specs/003-standalone-client/quickstart.md", + "accessCount": 14, + "lastAccessed": 1777870035001, + "type": "file" + }, { "path": "specs/003-standalone-client/plan.md", "accessCount": 13, "lastAccessed": 1777750552764, "type": "file" }, + { + "path": "pyproject.toml", + "accessCount": 13, + "lastAccessed": 1777867903357, + "type": "file" + }, { "path": "specs/004-secure-session/plan.md", "accessCount": 11, @@ -239,6 +252,18 @@ "lastAccessed": 1777829168275, "type": "file" }, + { + "path": "CLAUDE.md", + "accessCount": 11, + "lastAccessed": 1777831479114, + "type": "file" + }, + { + "path": "PLAYER.md", + "accessCount": 11, + "lastAccessed": 1777870046991, + "type": "file" + }, { "path": "specs/003-standalone-client/tasks.md", "accessCount": 10, @@ -263,36 +288,18 @@ "lastAccessed": 1777827349702, "type": "file" }, - { - "path": "pyproject.toml", - "accessCount": 10, - "lastAccessed": 1777829267614, - "type": "file" - }, { "path": "specs/003-standalone-client/data-model.md", "accessCount": 9, "lastAccessed": 1777748689564, "type": "file" }, - { - "path": "specs/003-standalone-client/quickstart.md", - "accessCount": 9, - "lastAccessed": 1777749902416, - "type": "file" - }, { "path": "README.md", "accessCount": 9, "lastAccessed": 1777754918429, "type": "file" }, - { - "path": "CLAUDE.md", - "accessCount": 9, - "lastAccessed": 1777792045335, - "type": "file" - }, { "path": "specs/004-secure-session/data-model.md", "accessCount": 9, @@ -366,9 +373,9 @@ "type": "file" }, { - "path": "PLAYER.md", + "path": ".specify/feature.json", "accessCount": 5, - "lastAccessed": 1777829220442, + "lastAccessed": 1777830776429, "type": "file" }, { @@ -377,6 +384,12 @@ "lastAccessed": 1777791778635, "type": "file" }, + { + "path": ".specify/templates/tasks-template.md", + "accessCount": 4, + "lastAccessed": 1777831528588, + "type": "file" + }, { "path": "specs/003-standalone-client/checklists/requirements.md", "accessCount": 3, @@ -389,18 +402,6 @@ "lastAccessed": 1777791070410, "type": "file" }, - { - "path": ".specify/feature.json", - "accessCount": 3, - "lastAccessed": 1777791146829, - "type": "file" - }, - { - "path": ".specify/templates/tasks-template.md", - "accessCount": 3, - "lastAccessed": 1777792667551, - "type": "file" - }, { "path": "risus.cfg.example", "accessCount": 3, diff --git a/.specify/feature.json b/.specify/feature.json index bb839a4..0372b90 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/004-secure-session" + "feature_directory": "specs/005-macos-signed-release" } diff --git a/AGENTS.md b/AGENTS.md index 72b9451..c2daa93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,11 +213,30 @@ Steps to publish a new release binary: 1. **Bump `pyproject.toml`** — update `version` field to next semver (`1.x.y`) 2. **Commit** the version bump: `build(deps): bump version to 1.x.y` 3. **Push** to `main` -4. **Tag and push tag**: +4. **Pre-tag: verify Apple signing secrets** — confirm all 6 secrets below are + configured in repository **Settings → Secrets and variables → Actions** + before pushing a release tag. Missing secrets cause the macOS signing job to + fail after the binary is built, blocking the release. +5. **Tag and push tag**: ```bash git tag v1.x.y git push origin v1.x.y ``` -5. GitHub Actions builds and publishes binaries automatically on tag push. +6. GitHub Actions builds, signs, notarizes, and publishes binaries automatically on tag push. **Hard rule:** Never push a release tag without first bumping `pyproject.toml` — the binary reports the version baked at build time. + +### Signing Setup + +The macOS job requires 6 GitHub Actions secrets. Add them under +**Settings → Secrets and variables → Actions → New repository secret**. +See `specs/005-macos-signed-release/quickstart.md` for full setup steps. + +| Secret | What it is | How to obtain | +|--------|-----------|---------------| +| `APPLE_CERTIFICATE` | Developer ID Application certificate + private key, exported as a `.p12` file and base64-encoded | Xcode → Settings → Accounts → Manage Certificates → right-click "Developer ID Application" → Export Certificate; run `base64 -i cert.p12 \| pbcopy` | +| `APPLE_CERTIFICATE_PASSWORD` | Password set when exporting the `.p12` file | The password you chose during the `.p12` export | +| `APPLE_SIGNING_IDENTITY` | Full codesign identity string | Run `security find-identity -v -p codesigning` and copy the full string, e.g. `Developer ID Application: Name (TEAMID)` | +| `APPLE_API_KEY_ID` | App Store Connect API key ID | App Store Connect → Users and Access → Integrations → Keys → Key ID | +| `APPLE_API_ISSUER_ID` | App Store Connect API issuer ID | Same page as above — Issuer ID shown at the top | +| `APPLE_API_KEY_CONTENT` | Contents of the `.p8` private key file for the API key | Downloaded once when creating the API key; contents of the `.p8` file | diff --git a/CLAUDE.md b/CLAUDE.md index eee18c1..393fb62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,5 +17,5 @@ Do not duplicate rules here. Update the constitution or AGENTS.md instead. For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan at -`specs/004-secure-session/plan.md`. +`specs/005-macos-signed-release/plan.md`. diff --git a/PLAYER.md b/PLAYER.md index 8069a80..44237d5 100644 --- a/PLAYER.md +++ b/PLAYER.md @@ -19,13 +19,7 @@ operating system: chmod +x risus-linux-x86_64 # replace with your filename ``` -**macOS note**: On first run macOS may block the app because it is not -notarized. To allow it: - -1. Try to run it once — macOS shows a security warning and blocks it. -2. Open **System Settings → Privacy & Security**. -3. Scroll down to the blocked app and click **Allow Anyway**. -4. Run the binary again — it will launch. +**macOS note**: The binary is notarized. Gatekeeper allows it on first run without any manual steps. ## 3. Run @@ -81,6 +75,6 @@ A template file `risus.cfg.example` is included in the release — copy it to | `Connection to … failed` | Check that the server is running and the address is correct | | Menu appears instantly, no players listed, no delay | Client did not connect. Check `risus.cfg` exists in the same folder as the binary. If the server uses TLS (e.g. `risus.example.com`), the address must have **no port** — a `host:port` address forces an unencrypted connection | | Permission denied (Unix) | Run `chmod +x ` first | -| macOS blocks the app | Follow Step 2 above | +| macOS blocks the app | Binary is notarized — if blocked, open System Settings → Privacy & Security and click Allow Anyway | | Windows SmartScreen warning | Click **More info → Run anyway** | | Slow startup (~2–4 s) | Normal — binary self-extracts on first launch | diff --git a/build/entitlements.plist b/build/entitlements.plist new file mode 100644 index 0000000..9d534f4 --- /dev/null +++ b/build/entitlements.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + + diff --git a/docker-compose.yml b/docker-compose.yml index 4804375..88fe1a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,8 @@ services: network_mode: host profiles: - production + environment: + DOMAIN: ${DOMAIN} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data diff --git a/pyproject.toml b/pyproject.toml index 157ceb5..a7f6f19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "risus-cli" -version = "1.0.4" +version = "1.0.5" requires-python = ">=3.12" dependencies = [] diff --git a/specs/003-standalone-client/quickstart.md b/specs/003-standalone-client/quickstart.md index c8cb6c6..28de829 100644 --- a/specs/003-standalone-client/quickstart.md +++ b/specs/003-standalone-client/quickstart.md @@ -20,13 +20,7 @@ operating system: chmod +x risus-linux-x86_64 # replace with your filename ``` -**macOS note**: On first run macOS may block the app because it is not -notarized. To allow it: - -1. Try to run it once — macOS shows a security warning and blocks it. -2. Open **System Settings → Privacy & Security**. -3. Scroll down to the blocked app and click **Allow Anyway**. -4. Run the binary again — it will launch. +**macOS note**: The binary is notarized. Gatekeeper allows it on first run without any manual steps. ## 3. Run @@ -81,5 +75,5 @@ to set initial defaults before your first session. | ------- | --- | | `No response from server` | Check that the server is running and the address/port is correct | | Permission denied (Unix) | Run `chmod +x ` first | -| macOS blocks the app | Follow Step 2 above | +| macOS blocks the app | Binary is notarized — if blocked, open System Settings → Privacy & Security and click Allow Anyway | | Windows SmartScreen warning | Click **More info → Run anyway** | diff --git a/specs/005-macos-signed-release/checklists/requirements.md b/specs/005-macos-signed-release/checklists/requirements.md new file mode 100644 index 0000000..96145b6 --- /dev/null +++ b/specs/005-macos-signed-release/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: macOS Signed Release + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-03 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +All items pass. Ready for `/speckit-plan`. diff --git a/specs/005-macos-signed-release/contracts/release-artifact.md b/specs/005-macos-signed-release/contracts/release-artifact.md new file mode 100644 index 0000000..e6f5a7f --- /dev/null +++ b/specs/005-macos-signed-release/contracts/release-artifact.md @@ -0,0 +1,54 @@ +# Contract: Release Artifact + +**Phase**: 1 | **Date**: 2026-05-03 + +Defines the contract for the macOS release artifact produced by the GitHub Actions release workflow. + +## Artifact Naming + +| Platform | Artifact filename | Checksum filename | +|----------|------------------|------------------| +| macOS arm64 | `risus-macos-arm64.zip` | `risus-macos-arm64.zip.sha256` | + +The zip contains: `risus-macos-arm64` (signed binary). + +## Verification Commands + +After downloading `risus-macos-arm64.zip` and extracting `risus-macos-arm64`: + +```bash +# Verify code signature +codesign --verify --deep --strict --verbose=2 risus-macos-arm64 + +# Verify Gatekeeper acceptance (no privacy exceptions required) +spctl --assess --type execute --verbose risus-macos-arm64 + +# Expected output from spctl: +# risus-macos-arm64: accepted +# source=Notarized Developer ID +``` + +## Signing Identity + +``` +Developer ID Application: () +``` + +## Required GitHub Actions Secrets + +| Secret name | Description | +|-------------|-------------| +| `APPLE_CERTIFICATE` | Base64-encoded `.p12` Developer ID Application certificate | +| `APPLE_CERTIFICATE_PASSWORD` | Passphrase for the `.p12` file | +| `APPLE_TEAM_ID` | 10-character Apple Developer team identifier | +| `APPLE_API_KEY_ID` | App Store Connect API key ID (10 chars) | +| `APPLE_API_ISSUER_ID` | App Store Connect issuer UUID | +| `APPLE_API_KEY_CONTENT` | Base64-encoded `.p8` API key file content | + +## Failure Modes + +| Failure | CI behaviour | +|---------|-------------| +| `codesign` exits non-zero | Job fails; no artifact uploaded | +| `notarytool` returns non-zero (Apple rejects or service error) | Job fails; no artifact uploaded | +| Certificate expired or revoked | `codesign` fails fast with non-zero exit | diff --git a/specs/005-macos-signed-release/data-model.md b/specs/005-macos-signed-release/data-model.md new file mode 100644 index 0000000..d3a5179 --- /dev/null +++ b/specs/005-macos-signed-release/data-model.md @@ -0,0 +1,64 @@ +# Data Model: macOS Signed Release + +**Phase**: 1 | **Date**: 2026-05-03 + +This feature has no runtime data model changes. The entities below describe the build-time and distribution artifacts. + +## Entities + +### Release Artifact + +The distributable file produced by the macOS release job. + +| Attribute | Value | +|-----------|-------| +| Name | `risus-macos-arm64.zip` | +| Contents | Signed, notarized `risus-macos-arm64` binary | +| Signing identity | Developer ID Application certificate (Developer ID Application: NAME (TEAM_ID)) | +| Hardened runtime | Enabled (`--options runtime`) | +| Entitlements | `com.apple.security.cs.disable-library-validation: true` | +| Notarization | Submitted and approved by Apple's notarization service | +| Gatekeeper result | `accepted` via online check on first run | + +### Developer ID Certificate + +| Attribute | Value | +|-----------|-------| +| Type | Developer ID Application (not Distribution, not App Store) | +| Format | `.p12` PKCS#12 archive | +| Storage | GitHub Actions secret `APPLE_CERTIFICATE` (base64-encoded) | +| Passphrase secret | `APPLE_CERTIFICATE_PASSWORD` | +| Keychain | Temporary per-job keychain, deleted after job | + +### App Store Connect API Key + +Used exclusively for `xcrun notarytool` authentication. + +| Attribute | Value | +|-----------|-------| +| Format | `.p8` private key file | +| Key ID | GitHub Actions secret `APPLE_API_KEY_ID` | +| Issuer ID | GitHub Actions secret `APPLE_API_ISSUER_ID` | +| Content | GitHub Actions secret `APPLE_API_KEY_CONTENT` (base64-encoded) | + +### Entitlements File + +| Attribute | Value | +|-----------|-------| +| Path | `build/entitlements.plist` | +| Format | Apple property list (XML) | +| Entitlements | `allow-jit`, `allow-unsigned-executable-memory`, `allow-dyld-environment-variables`, `disable-library-validation` (all `true`) | +| Rationale | Python runtime under Hardened Runtime requires all four; see research.md Decision 2 | + +## State Transitions + +``` +Binary built (unsigned) + → codesign applied (signed, hardened runtime) + → zipped + → notarytool submitted + → Apple scan: pass + → notarization approved (ticket in Apple DB) + → zip uploaded to GitHub Release + → user downloads → Gatekeeper online check → accepted → binary runs +``` diff --git a/specs/005-macos-signed-release/plan.md b/specs/005-macos-signed-release/plan.md new file mode 100644 index 0000000..8ca5cfc --- /dev/null +++ b/specs/005-macos-signed-release/plan.md @@ -0,0 +1,75 @@ +# Implementation Plan: macOS Signed Release + +**Branch**: `005-macos-signed-release` | **Date**: 2026-05-03 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/005-macos-signed-release/spec.md` + +## Summary + +Add Apple code signing and notarization to the macOS job in the existing GitHub Actions release workflow. The binary is built with PyInstaller, signed with a Developer ID Application certificate (hardened runtime + minimal entitlements), zipped, submitted to Apple's notarization service, and distributed as a notarized zip. Gatekeeper verifies the notarization ticket online on first run — no user privacy exceptions required. No runtime code changes. + +## Technical Context + +**Language/Version**: Python 3.12 +**Build Tool**: PyInstaller 6 (single-file binary, `build/risus.spec`) +**CI Platform**: GitHub Actions (`macos-latest` runner = Apple Silicon, arm64) +**Signing Tool**: `codesign` (Xcode Command Line Tools, pre-installed on macOS runners) +**Notarization Tool**: `xcrun notarytool` (Xcode 13+, pre-installed on macOS runners) +**Storage**: N/A +**Testing**: Manual verification via `codesign --verify` on produced artifact (`spctl --assess --type execute` is not used — it does not support plain Mach-O CLI binaries) +**Target Platform**: Latest macOS release, arm64 (`macos-latest` runner) +**Project Type**: CLI binary distribution +**Performance Goals**: Notarization completes within 15 minutes per release +**Constraints**: No hard-coded credentials; certificate and API key via GitHub Actions secrets only +**Scale/Scope**: One artifact per tagged release; macOS runner only (Linux and Windows jobs unchanged) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Server Authority | ✅ PASS | Build/release concern only; no runtime state changes | +| II. Simplicity | ✅ PASS | Touches only `release.yml` (macOS job) and adds `build/entitlements.plist`; zero UX or menu changes | +| III. No Duplication | ✅ PASS | Signing logic lives in one place (`release.yml` macOS job) | +| IV. Testing Discipline | ✅ PASS | No new runtime code; verification via `codesign --verify` on artifact | +| V. No Local Persistence | ✅ PASS | Build artifact, not runtime state | + +No violations. Complexity Tracking table omitted. + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-macos-signed-release/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ └── release-artifact.md # Phase 1 output +└── tasks.md # Phase 2 output (/speckit-tasks) +``` + +### Source Code (repository root) + +```text +build/ +├── risus.spec # Existing PyInstaller spec (unchanged) +└── entitlements.plist # NEW: minimal entitlements for signed binary + +.github/workflows/ +└── release.yml # MODIFIED: macOS job gains signing + notarization steps +``` + +**Structure Decision**: Single-project layout. Only build tooling files change; no src/ changes. + +## Notes + +- All signing steps guarded by `if: runner.os == 'macOS'` — Linux/Windows jobs unchanged +- Signing steps additionally gated on `env.APPLE_CERTIFICATE != ''` — branches/PRs without secrets produce an unsigned binary; tagged releases assert secrets are present and fail loudly if missing +- `APPLE_SIGNING_IDENTITY` holds the full codesign identity string (`Developer ID Application: Name (TEAMID)`) — verify via `security find-identity -v -p codesigning`; `APPLE_TEAM_ID` alone is not a valid identity +- `spctl --assess --type execute` is NOT used — fails for plain Mach-O CLI binaries; use `codesign --verify` instead +- `xcrun notarytool submit --wait` typically completes in 1–5 min; 15-min timeout well within GitHub Actions defaults +- Keychain cleanup uses `if: always()` — runs even if signing or notarization fails +- Only the zip (`risus-macos-arm64.zip`) is uploaded; bare binary not distributed diff --git a/specs/005-macos-signed-release/quickstart.md b/specs/005-macos-signed-release/quickstart.md new file mode 100644 index 0000000..0585a88 --- /dev/null +++ b/specs/005-macos-signed-release/quickstart.md @@ -0,0 +1,113 @@ +# Quickstart: macOS Signed Release + +**Date**: 2026-05-03 + +## What changes + +- `build/entitlements.plist` — new file; minimal entitlements for the signed binary +- `.github/workflows/release.yml` — macOS job gains certificate import, codesign, notarization, and zip steps + +Linux and Windows jobs are unchanged. + +## Prerequisites (one-time developer setup) + +### 1. Export the Developer ID Application certificate + +Use Xcode — not Keychain Access (the cert has no private key there). + +1. Open **Xcode → Settings → Accounts** → select your Apple ID → **Manage Certificates** +2. Right-click **Developer ID Application** (it looks like a static label but is clickable) → **Export Certificate** +3. Choose a save location and set a strong password +4. macOS prompts for your **login keychain password — twice**. Enter it both times. +5. You now have a `.p12` file. Keep it secret. + +Base64-encode for GitHub: + +```bash +base64 -i YourCert.p12 | pbcopy +``` + +**Delete the `.p12` file immediately after adding it to GitHub.** It is not encrypted at rest and contains the private key. + +### 2. Find your signing identity string + +```bash +security find-identity -v -p codesigning +``` + +Copy the full string from the output, e.g.: + +``` +Developer ID Application: Stefan Boos (M9YN683HBZ) +``` + +This is your `APPLE_SIGNING_IDENTITY` value. + +### 3. Create an App Store Connect API key + +1. Go to [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → **Users and Access → Integrations → App Store Connect API** +2. Click **Request Access** if you have not done so before — the **+** button is not available until access is granted +3. Click **+** → name the key (e.g. `risus-notarization`) → Access: **Developer** → **Generate** +4. Note the **Key ID** and **Issuer ID** shown on the page +5. Click **Download API Key** — saves `AuthKey_KEYID.p8` — **downloadable once only** + +Base64-encode for GitHub: + +```bash +base64 -i AuthKey_KEYID.p8 | pbcopy +``` + +### 4. Add GitHub Actions secrets + +Go to repository **Settings → Secrets and variables → Actions → New repository secret**: + +| Secret | Value | +|--------|-------| +| `APPLE_CERTIFICATE` | Base64-encoded `.p12` file | +| `APPLE_CERTIFICATE_PASSWORD` | Password set during `.p12` export | +| `APPLE_SIGNING_IDENTITY` | Full identity string, e.g. `Developer ID Application: Name (TEAMID)` | +| `APPLE_API_KEY_ID` | Key ID from App Store Connect | +| `APPLE_API_ISSUER_ID` | Issuer ID from App Store Connect | +| `APPLE_API_KEY_CONTENT` | Base64-encoded `.p8` file | + +### 5. Bump the version + +Before tagging, update `pyproject.toml`: + +```toml +version = "1.x.y" +``` + +The tag must match the version (e.g. `version = "1.0.5"` → tag `v1.0.5`). + +## Conditional signing + +Signing steps only run when `APPLE_CERTIFICATE` is configured as a repository secret. On branches and PRs where secrets are absent, the macOS job builds an unsigned binary — useful for verifying the build without Apple credentials. + +On **tagged releases**, the pipeline asserts that secrets are present and fails loudly if they are missing. This prevents accidentally publishing an unsigned artifact. + +## Triggering a release + +```bash +git tag v1.0.5 +git push origin v1.0.5 +``` + +The release workflow runs. The macOS job: +1. Builds the binary with PyInstaller +2. Imports the certificate into a temporary keychain +3. Signs the binary with `codesign` +4. Zips the binary +5. Submits the zip to Apple notarization (`xcrun notarytool submit --wait`) +6. Uploads `risus-macos-arm64.zip` to the GitHub Release + +## Verifying the artifact locally + +```bash +unzip risus-macos-arm64.zip +codesign --verify --deep --strict --verbose=2 risus-macos-arm64 +# Expected: risus-macos-arm64: valid on disk +# risus-macos-arm64: satisfies its Designated Requirement +``` + +Note: `spctl --assess --type execute` does not work for plain Mach-O CLI binaries and will report "does not seem to be an app" even when the binary is correctly signed and notarized. Use `codesign --verify` instead. diff --git a/specs/005-macos-signed-release/research.md b/specs/005-macos-signed-release/research.md new file mode 100644 index 0000000..4da68e6 --- /dev/null +++ b/specs/005-macos-signed-release/research.md @@ -0,0 +1,53 @@ +# Research: macOS Signed Release + +**Phase**: 0 | **Date**: 2026-05-03 + +## Decision 1: PyInstaller Binary Signing + +- **Decision**: Sign the PyInstaller single-file binary directly with `codesign --force --verbose --timestamp --sign "Developer ID Application: TEAM_NAME (TEAM_ID)" --options=runtime --no-strict --entitlements build/entitlements.plist`. +- **Rationale**: PyInstaller produces a single Mach-O executable. `--options runtime` enables Hardened Runtime (prerequisite for notarization). `--timestamp` embeds Apple's secure timestamp so the signature remains valid after certificate expiry. `--no-strict` avoids false validation errors on PyInstaller's internal structure. `--deep` is omitted — it is unnecessary for a single-file binary and can cause signing errors. +- **Alternatives considered**: Signing a `.app` bundle — unnecessary overhead for a CLI tool with no GUI. + +## Decision 2: Entitlements + +- **Decision**: Provide `build/entitlements.plist` with 4 entitlements: `com.apple.security.cs.allow-jit`, `com.apple.security.cs.allow-unsigned-executable-memory`, `com.apple.security.cs.allow-dyld-environment-variables`, `com.apple.security.cs.disable-library-validation` (all `true`). +- **Rationale**: PyInstaller embeds a Python runtime. Under Hardened Runtime, macOS enforces strict memory and library constraints that the Python interpreter requires relaxing: JIT for bytecode execution, unsigned executable memory for Python's allocator, DYLD variables for Python path resolution, and disabled library validation because embedded libraries are not individually Apple-signed. This set is confirmed by the malimo project (same signing approach for a .NET self-contained binary, analogous constraints). +- **Alternatives considered**: Using only `disable-library-validation` — insufficient; Python runtime also needs the JIT and memory entitlements. Signing all embedded libraries individually — impractical with PyInstaller's bundle structure. + +## Decision 3: Notarization Authentication + +- **Decision**: Use App Store Connect API key (`.p8` file) with `xcrun notarytool submit --key-id ... --issuer-id ... --key ...`. +- **Rationale**: API key authentication is stateless, requires no 2FA, and is the recommended approach for CI/CD. Apple ID + app-specific password requires storing an Apple ID email as a secret and can be blocked by Apple's security systems. +- **Alternatives considered**: Apple ID + app-specific password — works but requires more secrets and is more fragile in automated environments. Both approaches produce identical notarization results. +- **Secrets required**: + - `APPLE_API_KEY_ID` — 10-character key ID from App Store Connect + - `APPLE_API_ISSUER_ID` — UUID issuer ID from App Store Connect + - `APPLE_API_KEY_CONTENT` — base64-encoded `.p8` key file content + +## Decision 4: Notarization Submission Format + +- **Decision**: Zip the signed binary (`ditto -c -k --keepParent`) and submit the zip to `xcrun notarytool submit --wait`. +- **Rationale**: `notarytool` requires a zip, `.dmg`, or `.pkg` — not a bare binary. A zip is the simplest wrapper. `--wait` blocks until Apple completes the scan (typically 1–5 minutes), simplifying the CI step. +- **Alternatives considered**: `.pkg` wrapper — enables stapling (ticket embedded in artifact for offline Gatekeeper checks), but adds packaging complexity. Since the spec requires no privacy exceptions on download (not offline use), a zip with online Gatekeeper verification satisfies all requirements. + +## Decision 5: Stapling + +- **Decision**: Do not staple. Distribute the notarized zip as-is. +- **Rationale**: `xcrun stapler` cannot staple a notarization ticket to a bare binary or a zip archive — only to `.app`, `.dmg`, and `.pkg` files. On first run, macOS Gatekeeper performs an online check against Apple's notarization database and passes the binary. This satisfies FR-003 (`spctl --assess` accepted) without any user privacy exception. +- **Alternatives considered**: Wrapping in `.dmg` or `.pkg` to enable stapling — provides offline Gatekeeper verification but adds significant packaging steps. Out of scope per Simplicity principle; the spec does not require offline use. + +## Decision 6: Certificate Import in CI + +- **Decision**: Use `apple-actions/import-codesign-certs@v7` GitHub Action to import the `.p12` certificate. +- **Rationale**: The action handles temporary keychain creation, certificate import, partition list configuration, and cleanup automatically. Eliminates ~15 lines of fragile bash. Confirmed working in malimo project with identical secrets layout. +- **Secrets required**: + - `APPLE_CERTIFICATE` — base64-encoded `.p12` Developer ID Application certificate + - `APPLE_CERTIFICATE_PASSWORD` — passphrase for the `.p12` file + - `APPLE_TEAM_ID` — 10-character team identifier (used in the signing identity string) + +## Resolved Clarifications + +All NEEDS CLARIFICATION items from the spec have been resolved: +- Notarization service unavailability: `xcrun notarytool submit --wait` exits non-zero on Apple service errors; GitHub Actions will mark the job failed and no artifact is uploaded (satisfies FR-005). +- Certificate expiry: `codesign` fails fast with a non-zero exit code if the certificate is invalid or expired. +- Artifact portability: The notarization record is tied to the binary's hash in Apple's database; copying to another machine preserves the signature and Gatekeeper performs the same online check. diff --git a/specs/005-macos-signed-release/spec.md b/specs/005-macos-signed-release/spec.md new file mode 100644 index 0000000..4158202 --- /dev/null +++ b/specs/005-macos-signed-release/spec.md @@ -0,0 +1,99 @@ +# Feature Specification: macOS Signed Release + +**Feature Branch**: `005-macos-signed-release` +**Created**: 2026-05-03 +**Status**: Draft +**Input**: User description: "The macOS release shall not require the user to configure privacy exceptions. The developer is enrolled in the apple developer program." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Run CLI Without Security Prompts (Priority: P1) + +A macOS user downloads the Risus CLI release artifact and runs it immediately without needing to visit System Settings or approve any security exception. + +**Why this priority**: Any security prompt is a blocker — users who do not know how to bypass Gatekeeper cannot use the tool at all. This is the primary acceptance gate. + +**Independent Test**: Download the release artifact on a clean macOS machine, double-click or execute it in Terminal, and verify it runs without any "unidentified developer" dialog, Gatekeeper block, or privacy exception prompt. Delivers a fully usable CLI out of the box. + +**Acceptance Scenarios**: + +1. **Given** a macOS user downloads the release artifact, **When** they run it from Terminal for the first time, **Then** the CLI launches without any Gatekeeper warning or security exception dialog. +2. **Given** a macOS user is on macOS 13 (Ventura) or later, **When** they execute the artifact, **Then** macOS does not quarantine or block execution. +3. **Given** the artifact is downloaded via a browser, **When** the user runs it immediately, **Then** no "open anyway" step in System Settings is required. + +--- + +### User Story 2 - Developer Produces a Notarized Release Artifact (Priority: P2) + +A developer with an Apple Developer Program account runs the release process and produces an artifact that is code-signed and notarized, ready for distribution. + +**Why this priority**: Without a repeatable notarization workflow the P1 user story cannot be fulfilled on each release. + +**Independent Test**: Run the release build process in a macOS environment with valid Apple Developer credentials. Verify the produced artifact passes `codesign --verify` and carries a valid code signature. Delivers a distributable binary independent of any end-user steps. + +**Acceptance Scenarios**: + +1. **Given** a developer has valid Apple Developer Program credentials configured, **When** the release build process runs, **Then** it produces a signed and notarized artifact without manual intervention. +2. **Given** the produced artifact, **When** `codesign --verify` is run against it, **Then** the assessment passes with valid signature status. +3. **Given** the produced artifact, **When** `codesign --verify` is run against it, **Then** the signature is valid and the signing identity is a trusted Apple Developer ID. + +--- + +### User Story 3 - CI/CD Produces Consistent Notarized Artifacts (Priority: P3) + +The CI/CD pipeline produces a notarized macOS artifact on each tagged release without developer manual steps beyond providing credentials as secrets. + +**Why this priority**: Automates the notarization workflow so releases are repeatable and not gated on a specific developer's machine. + +**Independent Test**: Trigger a tagged release in CI. Confirm the produced macOS artifact in the release assets is notarized. Delivers automation independent of local developer environment. + +**Acceptance Scenarios**: + +1. **Given** a tagged release is pushed, **When** the CI pipeline runs, **Then** the macOS artifact in release assets is signed and notarized. +2. **Given** Apple Developer credentials are stored as CI secrets, **When** the pipeline runs, **Then** signing and notarization succeed without interactive input. + +--- + +### Edge Cases + +- What happens if the Apple notarization service is temporarily unavailable during CI? +- How does the artifact behave on macOS versions older than the minimum supported version? +- What if the Developer ID certificate is expired or revoked — does the build fail fast with a clear error? +- What happens if the user copies the artifact to another macOS machine — does the signature remain valid? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The macOS release artifact MUST be code-signed with a valid Apple Developer ID Application certificate. +- **FR-002**: The macOS release artifact MUST be submitted to Apple for notarization and approved; Gatekeeper verifies the notarization record online on first run. +- **FR-003**: The artifact MUST be accepted by Gatekeeper on a clean macOS system without any user-configured security exceptions. (`codesign --verify` confirms the signature; `spctl --assess` is not used — it does not support plain Mach-O CLI binaries.) +- **FR-004**: The release build process MUST accept Apple Developer credentials (signing identity, certificate, API key) via environment variables or secrets — no hard-coded credentials. +- **FR-005**: On tagged releases, the pipeline MUST fail with a clear error if signing secrets are absent or if code-signing or notarization fails, preventing distribution of an unsigned artifact. On non-tag pushes (branches, PRs), signing steps MAY be skipped when secrets are not configured, allowing unsigned build verification without Apple credentials. +- **FR-006**: The CI/CD pipeline MUST produce the notarized macOS artifact automatically on each tagged release. +- **FR-007**: The artifact MUST run on the latest macOS release without additional configuration by the user. + +### Key Entities + +- **Release Artifact**: The distributable macOS binary or archive of the Risus CLI, packaged for end-user download. +- **Developer ID Certificate**: The Apple-issued code-signing certificate associated with the enrolled Apple Developer Program account. +- **Notarization Ticket**: Apple's cryptographic attestation stapled to the artifact confirming it passed security checks. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A fresh macOS user can download and run the CLI in under 60 seconds with zero security dialogs or System Settings steps. +- **SC-002**: 100% of tagged releases produce a notarized macOS artifact via the automated pipeline. +- **SC-003**: `codesign --verify --deep --strict` passes on every distributed artifact. (Note: `spctl --assess --type execute` does not support plain Mach-O CLI binaries and is not used as a verification gate.) +- **SC-004**: The release build process completes notarization in under 15 minutes per release. +- **SC-005**: Zero support requests related to macOS Gatekeeper or "unidentified developer" blocks after the feature ships. + +## Assumptions + +- Developer is enrolled in the Apple Developer Program and has access to a valid Developer ID Application certificate. +- Credentials (Team ID, certificate, signing password) will be provided as CI secrets — not stored in the repository. +- The CLI is distributed as a standalone binary or archive (e.g., via GitHub Releases), not through the Mac App Store, so Developer ID signing applies rather than App Store signing. +- Only the latest macOS release is supported; older versions are out of scope. +- The existing CI/CD platform (GitHub Actions or equivalent) supports macOS runners capable of running the signing and notarization tools. +- No entitlements beyond what is required for a command-line tool are needed (no camera, microphone, or other privacy-sensitive access). diff --git a/specs/005-macos-signed-release/tasks.md b/specs/005-macos-signed-release/tasks.md new file mode 100644 index 0000000..4c84c21 --- /dev/null +++ b/specs/005-macos-signed-release/tasks.md @@ -0,0 +1,163 @@ +# Tasks: macOS Signed Release + +**Input**: Design documents from `/specs/005-macos-signed-release/` +**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅ + +**Organization**: Tasks grouped by user story. No tests requested in spec — no test tasks generated. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3) + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: New build artifact required before any signing step can run. + +- [x] T001 Create `build/entitlements.plist` with 4 entitlements required for PyInstaller Python runtime under Hardened Runtime: `com.apple.security.cs.allow-jit`, `com.apple.security.cs.allow-unsigned-executable-memory`, `com.apple.security.cs.allow-dyld-environment-variables`, `com.apple.security.cs.disable-library-validation` (all `true`) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Certificate import into CI keychain — MUST complete before any `codesign` call. + +**⚠️ CRITICAL**: T002 must be complete before US1 signing steps can run. + +- [x] T002 Add `apple-actions/import-codesign-certs@v7` step (`if: runner.os == 'macOS'`) to macOS matrix job in `.github/workflows/release.yml` with `p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}` and `p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}` — action handles keychain creation, import, and cleanup automatically; T003 no longer needed + +**Checkpoint**: Keychain lifecycle in place — US1 signing steps can now be added. + +--- + +## Phase 3: User Story 1 - Run CLI Without Security Prompts (Priority: P1) 🎯 MVP + +**Goal**: End user downloads and runs the CLI on macOS with zero Gatekeeper dialogs or System Settings steps. + +**Independent Test**: Download `risus-macos-arm64.zip` from GitHub Release, extract, run `codesign --verify --deep --strict --verbose=2 risus-macos-arm64` — output must show `valid on disk` and `satisfies its Designated Requirement`. + +### Implementation for User Story 1 + +- [x] T004 [US1] Add `codesign --force --verbose --timestamp --sign "$APPLE_SIGNING_IDENTITY" --options=runtime --no-strict --entitlements build/entitlements.plist dist/risus-macos-arm64` step (`if: runner.os == 'macOS'`) after binary rename in `.github/workflows/release.yml`; identity string sourced from `APPLE_SIGNING_IDENTITY` secret (full format: `Developer ID Application: Name (TEAMID)`) +- [x] T005 [US1] Add `ditto -c -k --keepParent dist/risus-macos-arm64 dist/risus-macos-arm64.zip` step (`if: runner.os == 'macOS'`) after signing in `.github/workflows/release.yml` +- [x] T006 [US1] Add `xcrun notarytool submit dist/risus-macos-arm64.zip --wait --key /tmp/apple_api_key.p8 --key-id $APPLE_API_KEY_ID --issuer $APPLE_API_ISSUER_ID` step (`if: runner.os == 'macOS'`, `timeout-minutes: 15`) in `.github/workflows/release.yml` — decode `APPLE_API_KEY_CONTENT` to `/tmp/apple_api_key.p8` before calling notarytool, delete the file after +- [x] T007 [US1] Update the "Compute checksum (Unix)" step in `.github/workflows/release.yml` to hash `risus-macos-arm64.zip` on macOS (not the bare binary) — use `if: runner.os == 'macOS'` for the new step and `if: runner.os == 'Linux'` for the existing Linux step +- [x] T008 [US1] Update the macOS artifact upload step in `.github/workflows/release.yml` — change `path` to upload `dist/risus-macos-arm64.zip` and `dist/risus-macos-arm64.zip.sha256` instead of the bare binary + +**Checkpoint**: Tag a release and confirm the macOS artifact in GitHub Releases is `risus-macos-arm64.zip`, passes `spctl --assess`, and requires no System Settings exception. + +--- + +## Phase 4: User Story 2 - Developer Produces Notarized Artifact (Priority: P2) + +**Goal**: Developer can verify the artifact is correctly signed and notarized using standard macOS tooling. + +**Independent Test**: Run `codesign --verify --deep --strict --verbose=2 risus-macos-arm64` against the downloaded artifact — exits 0 with `valid on disk`. (`spctl --assess --type execute` is not used — does not support plain Mach-O CLI binaries.) + +### Implementation for User Story 2 + +- [x] T009 [US2] Add post-notarization verification step (`if: runner.os == 'macOS'`) in `.github/workflows/release.yml` — run `codesign --verify --deep --strict --verbose=2 dist/risus-macos-arm64`; job fails if command exits non-zero. (`spctl --assess --type execute` removed — fails for plain Mach-O CLI binaries.) +- [x] T010 [P] [US2] Add "Signing Setup" subsection to the Release Checklist in `AGENTS.md` documenting the 6 required GitHub Actions secrets (`APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER_ID`, `APPLE_API_KEY_CONTENT`) and how to obtain each (reference `specs/005-macos-signed-release/quickstart.md` for full setup steps) + +**Checkpoint**: CI run produces a verifiable artifact; developer can confirm signing with `codesign` and `spctl` without any manual steps. + +--- + +## Phase 5: User Story 3 - CI/CD Consistent Notarized Artifacts (Priority: P3) + +**Goal**: Every tagged release automatically produces a notarized macOS artifact via GitHub Actions without developer intervention. + +**Independent Test**: Push a tag to a branch with secrets configured — GitHub Actions macOS job completes without manual steps and the release asset is `risus-macos-arm64.zip` with `source=Notarized Developer ID`. + +### Implementation for User Story 3 + +- [x] T011 [US3] Update `on.push.branches` in `.github/workflows/build.yml` to add `005-macos-signed-release` so CI runs on this feature branch during development +- [x] T012 [P] [US3] Add a pre-tag check to the Release Checklist in `AGENTS.md`: verify all 6 Apple signing secrets are configured in repository Settings → Secrets before pushing a release tag + +**Checkpoint**: Tagged release with secrets configured completes notarization automatically; secrets missing on a tag push causes clear CI failure before any artifact is uploaded. On branch/PR pushes without secrets, unsigned binary is built successfully (signing steps skipped). + +--- + +## Phase 6: Conditional Signing (FR-005 branch/tag behavior) + +**Goal**: Signing steps skip gracefully on branches/PRs without secrets; tagged releases assert secrets and fail loudly if missing. + +- [ ] T015 [US3] Gate all macOS signing steps in `.github/workflows/release.yml` on `env.APPLE_CERTIFICATE != ''` in addition to `runner.os == 'macOS'`; add an "Assert signing secrets" step (`if: runner.os == 'macOS' && github.ref_type == 'tag'`) that checks `APPLE_CERTIFICATE` is non-empty and exits 1 with a clear message if missing + +--- + +## Phase N: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation and release checklist hygiene. + +- [x] T013 [P] Update the macOS entry in the release workflow job name in `.github/workflows/release.yml` from `Build (macos-latest)` to `Build & Sign (macos-latest)` for clarity in the GitHub Actions UI +- [ ] T014 Run manual end-to-end verification per `specs/005-macos-signed-release/quickstart.md` against the first notarized release artifact + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **Foundational (Phase 2)**: Depends on T001 (entitlements must exist before signing) — **blocks all US phases** +- **US1 (Phase 3)**: Depends on T001, T002, T003 — core delivery +- **US2 (Phase 4)**: T009 depends on T006 (notarization must exist before verify step); T010 is independent [P] +- **US3 (Phase 5)**: T011 and T012 are independent [P] of US1/US2 implementation +- **Polish (Phase N)**: Depends on all US phases complete + +### User Story Dependencies + +- **US1 (P1)**: Depends on Foundational (T001–T003) — no dependency on US2/US3 +- **US2 (P2)**: T009 depends on US1 workflow steps existing; T010 is independent +- **US3 (P3)**: Independent of US1/US2 — parallel with either + +### Within US1 + +- T004 (sign) → T005 (zip) → T006 (notarize) → T007 (checksum) → T008 (upload): strictly sequential + +--- + +## Parallel Opportunities + +```bash +# Phase 1+2 can start in parallel with US3 tasks: +Task: T001 "Create build/entitlements.plist" +Task: T011 "Update build.yml branches list" +Task: T012 "Add pre-tag secrets check to AGENTS.md" + +# After Foundational complete, T010 runs in parallel with T004-T008: +Task: T010 "Add signing setup subsection to AGENTS.md" +Task: T004-T008 "US1 workflow signing steps (sequential)" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete T001 (entitlements file) +2. Complete T002–T003 (keychain lifecycle) +3. Complete T004–T008 (sign, zip, notarize, checksum, upload) +4. **STOP and VALIDATE**: push a test tag, confirm `spctl --assess` returns `accepted` +5. Add T009 (verification step) and T010 (docs) + +### Incremental Delivery + +1. T001 → T002–T003 → T004–T008 → validate (US1 complete, users unblocked) +2. T009 → T010 → validate (US2 complete, repeatable workflow documented) +3. T011 → T012 → validate (US3 complete, CI automation confirmed) +4. T013–T014 (polish) + +--- + +## Notes + +- All signing steps require `if: runner.os == 'macOS'` guards — Linux/Windows matrix jobs are unchanged +- `APPLE_TEAM_ID` is used in the signing identity string: `"Developer ID Application: $(APPLE_TEAM_ID)"` — confirm the exact identity string from `security find-identity -v -p codesigning` on the developer machine +- `xcrun notarytool submit --wait` typically takes 1–5 minutes; the 15-minute SC-004 limit is well within GitHub Actions job timeout defaults +- Keychain cleanup step uses `if: always()` so it runs even if signing or notarization fails +- The bare-binary artifact (`risus-macos-arm64`) is no longer uploaded; only the zip is distributed diff --git a/specs/006-zip-directory-structure/plan.md b/specs/006-zip-directory-structure/plan.md new file mode 100644 index 0000000..a29e48d --- /dev/null +++ b/specs/006-zip-directory-structure/plan.md @@ -0,0 +1,54 @@ +# Implementation Plan: macOS Zip Directory Structure + +**Branch**: `006-zip-directory-structure` | **Date**: 2026-05-04 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/006-zip-directory-structure/spec.md` + +## Summary + +Fix the macOS release zip so that extracting `risus-macos-arm64.zip` produces a directory named `risus-macos-arm64` (containing the binary) rather than a `dist` directory. The fix is a one-step change to the `ditto` packaging command in `release.yml`: stage the binary into a temporary directory with the correct name before zipping. + +## Technical Context + +**Language/Version**: Python 3.12 (build only — no runtime change) +**CI Platform**: GitHub Actions (`macos-latest` runner) +**Packaging Tool**: `ditto` (pre-installed on macOS runners) +**Constraint**: Binary must remain signed after repackaging — `ditto` preserves extended attributes and signatures +**Scale/Scope**: One-line change in `.github/workflows/release.yml`; no src/ or entitlements changes + +## Root Cause + +`ditto -c -k --keepParent dist/risus-macos-arm64 dist/risus-macos-arm64.zip` preserves the full path `dist/risus-macos-arm64` inside the archive. Unzipping produces `dist/risus-macos-arm64` — the `dist/` prefix is an artifact of the CI build directory layout. + +## Fix + +Stage the binary into `/tmp/risus-macos-arm64/` before zipping: + +```bash +mkdir -p /tmp/risus-macos-arm64 +cp dist/risus-macos-arm64 /tmp/risus-macos-arm64/ +ditto -c -k --keepParent /tmp/risus-macos-arm64 dist/risus-macos-arm64.zip +rm -rf /tmp/risus-macos-arm64 +``` + +`--keepParent` now preserves `/tmp/risus-macos-arm64` → zip entry becomes `risus-macos-arm64/risus-macos-arm64`. `ditto` preserves the code signature extended attributes during the copy. + +## Constitution Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Server Authority | ✅ PASS | Build/release concern only | +| II. Simplicity | ✅ PASS | Single step change in release.yml | +| III. No Duplication | ✅ PASS | Packaging logic in one place | +| IV. Testing Discipline | ✅ PASS | Verified via `unzip -l` and `codesign --verify` | +| V. No Local Persistence | ✅ PASS | Temp dir cleaned up in same step | + +## Project Structure + +Only `.github/workflows/release.yml` changes (the `Package signed binary` step). + +## Notes + +- `ditto` preserves extended attributes including code signature — copy does not break signing +- `/tmp/` is writable on GitHub Actions macOS runners +- Cleanup (`rm -rf`) runs in the same shell step — no orphaned temp dirs +- Checksum is computed after the zip is created, so sha256 automatically reflects the new structure diff --git a/specs/006-zip-directory-structure/spec.md b/specs/006-zip-directory-structure/spec.md new file mode 100644 index 0000000..3008161 --- /dev/null +++ b/specs/006-zip-directory-structure/spec.md @@ -0,0 +1,52 @@ +# Feature Specification: macOS Zip Directory Structure + +**Feature Branch**: `006-zip-directory-structure` +**Created**: 2026-05-04 +**Status**: Draft +**Input**: "When unzipping the release for macOS, the files shall be unzipped into a directory named as the basename of the zip file." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Unzip Produces Named Directory (Priority: P1) + +A macOS user downloads `risus-macos-arm64.zip` and unzips it. The result is a directory named `risus-macos-arm64` containing the binary — not a `dist` directory or bare file. + +**Why this priority**: Users who double-click the zip or run `unzip` get a sensible directory layout. A `dist/` directory appearing in the user's Downloads folder is confusing and looks like a build artifact leak. + +**Independent Test**: Download `risus-macos-arm64.zip`, run `unzip risus-macos-arm64.zip`, confirm the result is `risus-macos-arm64/risus-macos-arm64` (directory named after the zip basename containing the binary). + +**Acceptance Scenarios**: + +1. **Given** a user unzips `risus-macos-arm64.zip`, **When** extraction completes, **Then** a directory named `risus-macos-arm64` exists containing the binary `risus-macos-arm64`. +2. **Given** a user double-clicks the zip in Finder, **When** extraction completes, **Then** a folder named `risus-macos-arm64` appears — no `dist` folder. +3. **Given** the zip file, **When** `unzip -l risus-macos-arm64.zip` is run, **Then** the listing shows `risus-macos-arm64/risus-macos-arm64` (not `dist/risus-macos-arm64`). + +--- + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The macOS release zip MUST unzip into a directory whose name matches the zip file basename (`risus-macos-arm64`). +- **FR-002**: The binary inside the zip MUST be at path `risus-macos-arm64/risus-macos-arm64` within the archive. +- **FR-003**: The zip MUST remain a valid signed artifact — the binary inside must pass `codesign --verify` after extraction. +- **FR-004**: The sha256 checksum file MUST be recomputed after the zip is rebuilt with the new structure. + +### Key Entities + +- **Release Zip**: `risus-macos-arm64.zip` — the distributable macOS archive uploaded to GitHub Releases. +- **Staging Directory**: Temporary directory used during CI to produce the correct zip structure before signing/packaging. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: `unzip -l risus-macos-arm64.zip` lists `risus-macos-arm64/risus-macos-arm64` as the only entry (or top-level directory). +- **SC-002**: After extraction, `codesign --verify --deep --strict risus-macos-arm64/risus-macos-arm64` exits 0. +- **SC-003**: No `dist/` directory appears in the zip or after extraction. + +## Assumptions + +- Only the macOS artifact is affected — Linux and Windows artifacts are bare binaries, unchanged. +- The binary filename inside the zip remains `risus-macos-arm64` (same as the zip basename). +- Change is confined to the `ditto` packaging step in `.github/workflows/release.yml`. diff --git a/specs/006-zip-directory-structure/tasks.md b/specs/006-zip-directory-structure/tasks.md new file mode 100644 index 0000000..4266a81 --- /dev/null +++ b/specs/006-zip-directory-structure/tasks.md @@ -0,0 +1,29 @@ +# Tasks: macOS Zip Directory Structure + +**Input**: Design documents from `/specs/006-zip-directory-structure/` +**Prerequisites**: plan.md ✅, spec.md ✅ + +--- + +## Phase 1: Implementation + +- [ ] T001 Update the `Package signed binary` step in `.github/workflows/release.yml` — replace the single `ditto` line with a 4-line sequence: (1) `mkdir -p /tmp/risus-macos-arm64`, (2) `cp dist/risus-macos-arm64 /tmp/risus-macos-arm64/`, (3) `ditto -c -k --keepParent /tmp/risus-macos-arm64 dist/risus-macos-arm64.zip`, (4) `rm -rf /tmp/risus-macos-arm64` + +--- + +## Phase 2: Verification + +- [ ] T002 After next tagged release, run `unzip -l risus-macos-arm64.zip` on the downloaded artifact — confirm top-level entry is `risus-macos-arm64/` (not `dist/`); run `codesign --verify --deep --strict risus-macos-arm64/risus-macos-arm64` — exits 0 + +--- + +## Dependencies + +- T001 has no dependencies — change is isolated to one step +- T002 depends on T001 and a successful tagged release + +## Acceptance + +SC-001: `unzip -l` shows `risus-macos-arm64/risus-macos-arm64` +SC-002: `codesign --verify` exits 0 after extraction +SC-003: No `dist/` in the zip or after extraction