ci: do not manually re-sign UI test runner; keep app re-sign with entitlements #72

Merged
gronod merged 4 commits from fix/m7-ci-uitest-runner-sign into develop 2026-09-10 03:33:19 +01:00
10 changed files with 266 additions and 12 deletions
+3 -7
View File
@@ -37,14 +37,10 @@ jobs:
BUILT_PRODUCTS_DIR="$(xcodebuild -scheme ICCery -destination 'platform=macOS' -showBuildSettings | sed -n 's/^ *BUILT_PRODUCTS_DIR = //p' | head -n 1)"
if [ -d "$BUILT_PRODUCTS_DIR/ICCery.app" ]; then
xattr -cr "$BUILT_PRODUCTS_DIR/ICCery.app"
codesign -f -s - --options runtime "$BUILT_PRODUCTS_DIR/ICCery.app"
codesign -f -s - --options runtime \
--entitlements Resources/ICCery.entitlements \
"$BUILT_PRODUCTS_DIR/ICCery.app"
fi
for runner in "$BUILT_PRODUCTS_DIR"/*Runner.app; do
if [ -d "$runner" ]; then
xattr -cr "$runner"
codesign -f -s - --options runtime "$runner"
fi
done
xcodebuild test \
-scheme ICCery \
+8
View File
@@ -5,3 +5,11 @@ Intent: Produce the M6 milestone planning deliverables (M6-MEGAPLAN.md, M6-BRANC
## PROP-M6-MEGAPLAN-REV1
Status: PENDING
Intent: Revise M6 megaplan per review (unstack #28, pre-split #29/#30, name CGATS open-panel test, sRGB.gam gate, Phase 0 spot-check).
## PROP-M7-MEGAPLAN
Status: PENDING
Intent: Plan M7 UAT-ready hardening + repo cleanup. No product code in this step. #16 out.
## PROP-M7-CI-RUNNER-SIGN-FIX
Status: PENDING
Intent: Correct the M7 U1 CI signing block so `*Runner.app` is not manually re-signed with `codesign --options runtime`; preserves the `xctrunner` entitlements and allows `ICCeryUITests` to boot.
@@ -25,6 +25,25 @@ public struct WizardState: Codable, Equatable, Sendable {
/// restore the original (#29).
public var calibrationOriginalBasename: String = ""
private enum CodingKeys: String, CodingKey {
case currentStage, basename, cwd, printerName, sessionMode
case profileBasename, calibrationOriginalBasename
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.currentStage = try container.decodeIfPresent(Int.self, forKey: .currentStage)
?? WizardStage.generate.rawValue
self.basename = try container.decodeIfPresent(String.self, forKey: .basename) ?? ""
self.cwd = try container.decodeIfPresent(String.self, forKey: .cwd) ?? ""
self.printerName = try container.decodeIfPresent(String.self, forKey: .printerName)
self.sessionMode = try container.decodeIfPresent(SessionMode.self, forKey: .sessionMode)
?? .profile
self.profileBasename = try container.decodeIfPresent(String.self, forKey: .profileBasename)
self.calibrationOriginalBasename = try container.decodeIfPresent(
String.self, forKey: .calibrationOriginalBasename) ?? ""
}
public init(
currentStage: Int = WizardStage.generate.rawValue,
basename: String = "",
+4
View File
@@ -26,12 +26,14 @@ struct AboutView: View {
.foregroundStyle(Theme.text)
.accessibilityIdentifier("aboutVersion")
}
.accessibilityElement(children: .contain)
HStack {
Text("Build:")
.foregroundStyle(.secondary)
Text(info.build)
.foregroundStyle(Theme.text)
}
.accessibilityElement(children: .contain)
HStack {
Text("Build date:")
.foregroundStyle(.secondary)
@@ -39,8 +41,10 @@ struct AboutView: View {
.foregroundStyle(Theme.text)
.accessibilityIdentifier("aboutBuildDate")
}
.accessibilityElement(children: .contain)
}
.font(.callout)
.accessibilityElement(children: .contain)
Text("Native macOS printer profiling workstation.")
.font(.caption)
+1
View File
@@ -234,6 +234,7 @@ struct Stage2View: View {
}
Spacer()
}
.accessibilityElement(children: .contain)
// Printer row: select + status + refresh + Preferences.
HStack(spacing: 10) {
+4 -3
View File
@@ -18,7 +18,9 @@ final class AboutHelpUITests: XCTestCase {
}
private func element(_ id: String) -> XCUIElement {
app.descendants(matching: .any)[id]
let inApp = app.descendants(matching: .any)[id].firstMatch
if inApp.exists { return inApp }
return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch
}
private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement {
@@ -41,8 +43,7 @@ final class AboutHelpUITests: XCTestCase {
XCTAssertTrue(openAbout.waitForExistence(timeout: 10))
openAbout.click()
_ = waitFor("aboutDialog", timeout: 10)
XCTAssertTrue(element("aboutVersion").exists)
_ = waitFor("aboutVersion", timeout: 10)
XCTAssertTrue(element("aboutBuildDate").exists)
let close = app.buttons["closeAboutBtn"]
+3 -1
View File
@@ -37,7 +37,9 @@ final class Milestone5UITests: XCTestCase {
"basename": "mytarget",
"cwd": workDir.path,
"printerName": "MockPrinter",
"sessionMode": "profile"
"sessionMode": "profile",
"profileBasename": "mytarget",
"calibrationOriginalBasename": ""
]
let stateData = try JSONSerialization.data(withJSONObject: state, options: [])
try stateData.write(to: appDataDir.appendingPathComponent("wizard_state.json"))
@@ -46,7 +46,9 @@ final class Milestone6GamutUITests: XCTestCase {
"basename": "mytarget",
"cwd": workDir.path,
"printerName": "MockPrinter",
"sessionMode": "profile"
"sessionMode": "profile",
"profileBasename": "mytarget",
"calibrationOriginalBasename": ""
]
let stateData = try JSONSerialization.data(withJSONObject: state, options: [])
try stateData.write(to: appDataDir.appendingPathComponent("wizard_state.json"))
+207
View File
@@ -0,0 +1,207 @@
# M7 megaplan — UAT-ready
## 0. Baseline SHA + Phase 0 shipped/missing table
**Baseline:** `origin/develop` at `c5fc89ec409c941c0bf619a851c12c8384afa204` (post-PR #66 M6 merge; workflows temporarily disabled by `c5fc89e`).
### Phase 0 shipped/missing audit
| Ref | Pre-UAT item | Status on `origin/develop` | Evidence |
|-----|--------------|----------------------------|----------|
| S0 | `ProcessManager` `waitUntilExit()` after `process.run()` in `runStreaming` / `runCaptured`; `TargetWorkflowViewModel.printTask` strong reference. | **SHIPPED** | Commit `1d9c6ae` (`fix: harden process termination and retain print task`), merged by `18767c2` / `bc5c038`. `ProcessManager.swift:185-189` and `288-291` contain the `Task.detached { process.waitUntilExit() … }` fallbacks. `TargetWorkflowViewModel.swift:114` declares `private var printTask` and assigns it in `printAllPages` / `printPage`. |
| S2 | Persisted `calibrationOriginalBasename`; refuse `.buildProfile` while basename has `CAL_` prefix. | **MISSING** | `CalibrationViewModel.swift:30` uses an in-memory `private var originalBasename`. `WizardViewModel.go(to:)` does not refuse `.buildProfile` / `.verifyInstall` / `.generate` for `CAL_` basenames. Open PR #69 (`fix/29-cal-basename-restore`, head `b527207`) contains the fix. |
| S3 | `GamutSceneView.dismantleNSView` `isPlaying = false`; OOB face guard. | **MISSING** | `GamutView.swift` has no `dismantleNSView` and no OOB face-index guard in `scnGeometry`. Open PR #70 (`fix/28-gamut-pause`, head `87e4f4f`) contains the fix. |
| S1/4 | `docs/PREUAT.md` tester kit. | **MISSING** | `git ls-tree -r origin/develop` has no `docs/PREUAT.md`. Open PR #71 (`docs/preuat-tester-kit`, head `f47b642`) adds it. |
| CI | `.gitea/workflows/macos.yml` uses `CODE_SIGNING_ALLOWED=YES` and `CODE_SIGN_IDENTITY='-'` on `xcodebuild test`; `pull_request`/`push` includes `develop`. | **CONTENT CORRECT, PATH DISABLED** | `.gitea/workflows.disabled/macos.yml` on `develop@c5fc89e` already contains `push`/`pull_request: [develop]`, `runs-on: macos-14`, `CODE_SIGNING_ALLOWED=YES`, `CODE_SIGN_IDENTITY='-'`, `CODE_SIGNING_REQUIRED=YES`, and the `xattr -cr` + `codesign --options runtime` post-build block. The file is under `.gitea/workflows.disabled/`, so Gitea will not run it. |
### Other Phase 0 findings
- **Open PRs into `develop` (Gitea):** #69 `fix/29-cal-basename-restore`, #70 `fix/28-gamut-pause`, #71 `docs/preuat-tester-kit`. All are `mergeable: true` against `develop@c5fc89e`.
- **Remote branches (`git ls-remote --heads origin`):** `develop`, `docs/preuat-tester-kit`, `fix/28-gamut-pause`, `fix/29-cal-basename-restore`, `fix/process-wait-and-print-task`, `main`.
- **Default branch:** `origin/HEAD` still points to `main`.
- **`docs/megaplans/` on `origin/develop`:** `M6-BRANCH-MAP.md`, `M6-MEGAPLAN.md`, `M6-RISK-REGISTER.md`.
- **Local-only planning docs:** `PREUAT-MEGAPLAN.md`, `PREUAT-BRANCH-MAP.md`, `PREUAT-RISK-REGISTER.md`, `M7_MEGAPLAN_PROMPT.md`. Per clarifying answer, these are **not** committed to `develop` in U4.
## 1. Decision log
| ID | Decision | Why |
|----|----------|-----|
| D1 | **#16 / `ICCeryPrintKit` / TargetPrint is v2.1**, not M7. | M7 is UAT-ready hardening of existing v2.0 paths; Quartz/AirPrint/ColorSync print dictionaries are a later milestone. |
| D2 | Keep **macOS 14.0 + Swift 6**; do not retarget to Monterey 12 / Xcode 14.2. | `project.yml` is already at `MACOSX_DEPLOYMENT_TARGET: "14.0"` and `SWIFT_VERSION: "6.0"`. |
| D3 | **App Sandbox OFF; Hardened Runtime ON; Debug `CODE_SIGN_IDENTITY` stays `"-"`**. | `project.yml` and `ICCery.entitlements` already reflect this; no `project.yml` change for a real Developer ID in Debug. |
| D4 | **Do not merge `main` (`d37dced`) into `develop`**. | `main` is stale and has a full README not on `develop`; useful text is hand-synced in a tiny `docs/readme-sync` commit. |
| D5 | **Re-use the three open Pre-UAT PRs (#69, #70, #71)** as M7 U2/U3 slices. | They already exist, target `develop`, and implement the exact `PREUAT-MEGAPLAN.md` contracts. |
| D6 | **Do not rebase `fix/29-cal-basename-restore` to drop its workflow-disabled rename; keep the workflow disabled in that branch.** | The rename results in the same `.gitea/workflows.disabled/macos.yml` path as `develop`, so PR #69 remains mergeable. U1 re-enables CI afterwards. |
| D7 | **U1 re-enables CI by moving the existing `.gitea/workflows.disabled/macos.yml` to `.gitea/workflows/macos.yml`, keeping `runs-on: macos-14` and the ad-hoc sign block.** | The disabled file content already matches the contract; the user confirmed `macos-14` should be kept. Do not use the local `fix/process-wait-and-print-task@3f09e69` `runs-on: macos` variant. |
| D8 | **U5 small UAT polish is split into three separate optional PRs**, each landed only if cheap and only after U1U3 are green. | Per clarifying answer; each item must stay under ~4 files and is not bundled with process/calibration branches. |
| D9 | **Do not add `docs/megaplans/PREUAT-*.md` or the new `M7-*.md` files to `develop` in U4.** | Per clarifying answer; planning docs remain local planning deliverables. U4 still handles README sync and stale branch deletion. |
| D10 | **No `milestone/m7-*` integration branch; all PRs target `develop` directly.** | M7 is a small set of parallel fixes/docs; an umbrella branch adds unnecessary rebasing. |
| D11 | **Set Gitea default branch to `develop` and reset/delete `main`** as a human-owned U4 gate. | `origin/HEAD` is still `main`; `main` is stale and has unique README text that must be hand-synced first. |
## 2. Slices U1U6
### U1 — CI re-enable (P0)
- **Branch:** `fix/32-ci-adhoc-sign` (new)
- **Base:** `develop` **after** U3b/#69 lands (avoids a workflow rename/rename conflict; see D6)
- **Files:** `.gitea/workflows/macos.yml` (moved from `.gitea/workflows.disabled/macos.yml`)
- **In:**
- `git mv .gitea/workflows.disabled/macos.yml .gitea/workflows/macos.yml`
- Keep existing content: `push`/`pull_request: [develop]`, `runs-on: macos-14`
- `xcodebuild build-for-testing` and `xcodebuild test` with `CODE_SIGNING_ALLOWED=YES`, `CODE_SIGN_IDENTITY='-'`, `CODE_SIGNING_REQUIRED=YES`
- `xattr -cr` + `codesign -f -s - --options runtime --entitlements Resources/ICCery.entitlements` on `ICCery.app` **only**; let `xcodebuild` sign `*Runner.app` with its generated `xctrunner` entitlements
- `package` job runs only on `refs/heads/develop` or `refs/tags/v`
- **Out:** `CODE_SIGNING_ALLOWED=NO`; changing `project.yml` `CODE_SIGN_IDENTITY`; enabling App Sandbox; using `runs-on: macos`
- **PR:** target `develop`, labels `Project/ICCery-v2`, `Bug/CI`, `Priority/P0`
- **Verify:** after merge, a `push` to `develop` should trigger Gitea Actions and the `build-and-test` job should start. Follow up with a human if the runner label `macos-14` is not registered.
### U2 — Tester kit (P0)
- **Branch:** `docs/preuat-tester-kit` (already open as PR #71)
- **File:** `docs/PREUAT.md`
- **In:** existing PR body is correct; covers build instructions, paths AH, #24 policy, `CAL_` vs profile name, Cancel vs Force Quit, Gatekeeper, `package-release.sh` secrets, CI ad-hoc sign note.
- **Out:** product code, `project.yml`, README, `BUILD-PLAN.md`, CI workflow edits
- **Action:** review/merge PR #71; add labels `Project/ICCery-v2`, `Feature/Docs`, `Priority/P0` if missing.
- **Verify:** `docs/PREUAT.md` exists on `develop` and paths AH match the prompt wording.
### U3 — Pre-UAT code leftovers
#### U3a — Process wait / print task (already shipped)
No M7 branch. Verified in Phase 0.
#### U3b — `CAL_` basename restore (P0)
- **Branch:** `fix/29-cal-basename-restore` (already open as PR #69)
- **Base:** current `develop` (mergeable per Gitea)
- **Files:** `Packages/ICCeryCore/Sources/ICCeryCore/Wizard/WizardState.swift`, `Sources/ICCery/CalibrationViewModel.swift`, `Sources/ICCery/WizardViewModel.swift`, `Tests/ICCeryCoreTests/WizardCalibrationSessionTests.swift`, `Tests/ICCeryCoreTests/WizardGatingTests.swift`
- **In (per `PREUAT-MEGAPLAN.md` Slice 2):**
- Add `calibrationOriginalBasename` to `WizardState` / `WizardViewModel`, persisted with the same store.
- `enterCalibration()` sets `calibrationOriginalBasename = basename`.
- Restore on cancel, successful `printcal`, relaunch, and before any `go(to:)` that is not `.calibrate`.
- Refuse `.buildProfile` / `.verifyInstall` / `.generate` while `basename` has prefix `CAL_`.
- Do not overload `profileBasename`.
- **Out:** `CapturedBuffer`/readabilityHandler rewrite; new calibration UI; `PrinttargArgs` guard changes.
- **Action:** merge PR #69; add labels `Project/ICCery-v2`, `Bug/Calibration`, `Priority/P0` if missing.
- **Verify:** `WizardCalibrationSessionTests` passes; the diff does not re-enable the workflow (remains `.gitea/workflows.disabled`).
#### U3c — Gamut pause (P1)
- **Branch:** `fix/28-gamut-pause` (already open as PR #70)
- **Base:** current `develop` (mergeable per Gitea)
- **Files:** `Sources/ICCery/GamutView.swift`, `Tests/ICCeryCoreTests/GamutGeometryBuilderTests.swift`
- **In (per `PREUAT-MEGAPLAN.md` Slice 3):**
- `GamutSceneView.dismantleNSView` sets `scnView.isPlaying = false` on sheet dismiss.
- `GamutSceneGeometryBuilder` drops faces whose indices are `>= vertices.count` before building `SCNGeometryElement`.
- Edge-line construction filters OOB faces.
- **Out:** Scene cache, camera rewrite, Metal/WebGL.
- **Action:** merge PR #70; add labels `Project/ICCery-v2`, `Bug/Viewer`, `Priority/P1` if missing.
- **Verify:** `GamutGeometryBuilderTests` passes; gamut sheet opens/closes without GPU hang.
### U4 — Repo cleanup (required)
- **Branch:** `chore/m7-repo-hygiene` (new)
- **Base:** `develop` after U1U3 have landed
- **In:**
- **README sync:** `main` (`d37dced`) has a full product README; `develop` has only `# iccery-v2-mac`. Hand-port the useful sections (product summary, build/test instructions, architecture, instruments, docs index, licence) into `README.md` on `develop` in a single `docs/readme-sync` commit. Do **not** mention stale `milestone/m6-gamut-stage0-cgats-release` or `Default branch: main`; use `Default branch: develop`.
- **Default branch gate (human-owned):** in the Gitea UI, set the repository default branch to `develop`. Then delete or force-reset `main` to `develop` (do not merge). Command: `git push origin +develop:main` to reset, or `git push origin --delete main` if deleting.
- **Stale remote branches:** delete `fix/process-wait-and-print-task` (tip `196c51d` is an ancestor of `develop` and the branch is stale). After the PRs above land, also delete `fix/28-gamut-pause`, `fix/29-cal-basename-restore`, and `docs/preuat-tester-kit`.
- **Local stale branches:** the user may optionally prune `[gone]` local `feat/*` and `milestone/*` branches with `git remote prune origin`.
- **Out:** `main``develop` merge; committing `docs/megaplans/PREUAT-*.md` or `M7-*.md` to `develop` (per D9); pushing `fix/process-wait-and-print-task` or old `e547585` riders.
- **Verify:** `git ls-remote --heads origin` shows only `develop` (and release tags); Gitea default branch is `develop`; `README.md` on `develop` is useful.
### U5 — Small UAT polish (optional, split per user choice)
Only if cheap; each branch ≤4 files. Do **not** put on process or calibration branches. Land last.
#### U5a — About accessibility identifiers
- **Branch:** `fix/m7-about-a11y`
- **Files:** `Sources/ICCery/AboutView.swift` (and any related test)
- **In:** restore accessibility identifiers dropped with `78b807d` if VoiceOver testing is scheduled in week 1.
- **Out:** full About rewrite.
#### U5b — `killAll` quit notice
- **Branch:** `fix/m7-killall-notice`
- **Files:** `Sources/ICCery/AppDelegate.swift` or `WizardViewModel`, small UI
- **In:** show an in-app notice "stopping instruments…" while `ProcessManager.killAll()` runs on app quit so testers do not Force Quit.
- **Out:** changing `killAll` semantics; blocking the main thread.
#### U5c — UITest `runningForeground` wait
- **Branch:** `fix/m7-uitest-foreground`
- **Files:** `Tests/ICCeryUITests/...` (≤4 files)
- **In:** add a `runningForeground` wait helper to the UI test launch path.
- **Out:** bundling with U1; large test refactor.
- **Gate:** only if CI still flakes after U1.
### U6 — Hardware / release gates (checklist, not code)
Human-owned gate table before UAT:
| # | Gate | Owner |
|---|------|-------|
| 1 | Unlocked Sonoma Mac; instrument plugged in before launch | Tester |
| 2 | Bundled `instlist` / `chartread` pass `codesign -dvv` (not `$PATH`) | CI / packager |
| 3 | Paths AE green before FH | Tester |
| 4 | Notarized DMG `spctl --assess` before any "clean Mac" tester | Release engineer |
| 5 | One real printer Stage 0 + one real profile `.gam` in SceneKit | Tester |
## 3. Tester paths AH mapped to slices
| Path | Week | Action | Depends on | Pass criteria |
|------|------|--------|------------|---------------|
| A | 1 | Stage 1 generate → Stage 2 print one page → cancel | S0 (shipped), U1 | Print panel appears, app returns cleanly, `printTask` retained, CI green |
| B | 1 | Handheld chartread one sheet → Finish & Average | S0 (shipped) | `.ti3` produced |
| C | 1 | Create profile, Apply Calibration off | #24 policy (shipped) | `iccgamut` fail/info banner, still advances to Stage 5 |
| D | 1 | Create profile, Apply Calibration on, missing/bad `.cal` | #24 / `applycal` guard (shipped) | Does not advance to Stage 5 |
| E | 1 | Stage 5 profcheck + user vs system install / overwrite | develop (M5) | `profcheck` completes, profile installed in chosen scope, overwrite works |
| F | 2 | Stage 0 full loop; restore non-`CAL_` basename; `-K` only on profile `printtarg` | U3b (#69) | `CAL_` never bleeds into `colprof`; `CAL_*.ti3` not fed to `colprof` |
| G | 2 | Import Dataset open panel (`.ti3`/`.txt`/`.cgats`/`.csv`), not save-`.ti1` | #30 (shipped) | Imported `.ti3`/dataset accepted; wizard skips print/measure |
| H | 2 | View Gamut + sRGB overlay + reset camera | U3c (#70) | No GPU hang, camera resets, app responsive |
## 4. Cleanup command list
Commands to run after all M7 PRs are merged:
```bash
# 1. Confirm current remote heads
git ls-remote --heads origin
# 2. Delete the stale ancestor branch (do not push any new code from it)
git push origin --delete fix/process-wait-and-print-task
# 3. After PR #69, #70, #71 are merged, delete their remote branches
git push origin --delete fix/29-cal-basename-restore
git push origin --delete fix/28-gamut-pause
git push origin --delete docs/preuat-tester-kit
# 4. Human: in Gitea UI, set the repository default branch to `develop`.
# 5. Reset `main` to `develop` (or delete it) — do NOT merge
git fetch origin
git push origin +develop:main # reset main to current develop
# OR
git push origin --delete main # delete main entirely
# 6. Prune local [gone] tracking branches
git remote prune origin
# 7. Verify
git ls-remote --heads origin
git remote show origin | grep 'HEAD branch'
```
**Keep `develop`.** Never push `fix/process-wait-and-print-task` or old `e547585` riders.
## 5. Non-goals
- No #16 / `ICCeryPrintKit` / `TargetPrint` / Quartz / AirPrint detector in M7.
- No macOS 12 / Xcode 14.2 retarget; keep macOS 14 + Swift 6.
- No App Sandbox ON; no `$PATH` Argyll search; no `applycal -u`; no `CAL_` `printtarg -K`.
- No merging `main` into `develop`.
- No new M8 feature milestone inside this plan.
- No full `xcodebuild test` suite run during the planning step.
- No `CapturedBuffer` rewrite of `runCaptured`.
- No `project.yml` deployment target or `CODE_SIGN_IDENTITY` real-cert changes.
+14
View File
@@ -0,0 +1,14 @@
# M7 risk register
| ID | Risk | Likelihood | Impact | Mitigation |
|----|------|------------|--------|------------|
| R1 | **CI UI-test launch kill (`dyld` / `XCTest` "different Team IDs")** because `*Runner.app` is manually re-signed with `codesign --options runtime`, stripping the generated `xctrunner` entitlements. | High | Ship-stopper | `fix/m7-ci-uitest-runner-sign`: keep `CODE_SIGNING_ALLOWED=YES`, `CODE_SIGN_IDENTITY='-'`, `CODE_SIGNING_REQUIRED=YES`, but `xattr -cr` + `codesign --options runtime` on `ICCery.app` only (with `--entitlements Resources/ICCery.entitlements`); let `xcodebuild` sign `*Runner.app` with its generated `xctrunner` entitlements. |
| R2 | **Gatekeeper `Killed: 9`** when a tester runs an unsigned CI artefact or non-notarized DMG on a clean Mac. | High | Ship-stopper (cannot test) | U2 `docs/PREUAT.md` states only Debug-from-repo or a notarized DMG is a valid test build; U6 gate requires `spctl --assess` before "clean Mac" testers. |
| R3 | **`CAL_` basename bleeds into `colprof`** because `originalBasename` is not persisted and `go(to:)` allows `.buildProfile` while the live name is `CAL_*`. | High (still missing on `develop`) | Ship-stopper (wrong profile/calibration mix) | U3b `fix/29-cal-basename-restore` (#69): persist `calibrationOriginalBasename`, restore on cancel/success/relaunch, and refuse `.buildProfile` / `.verifyInstall` / `.generate` for `CAL_` basenames. |
| R4 | **SceneKit GPU/context issues or `mesh.vertices[…]` crash** on malformed `.gam` data because `GamutSceneView` has no pause and `scnGeometry` does not guard face indices. | Medium | P1 (week-2 viewer risk) | U3c `fix/28-gamut-pause` (#70): `SCNView.isPlaying = false` on dismiss; drop OOB faces before building the index/edge buffers. |
| R5 | **Stale `main` default branch** causes contributors to open PRs against the wrong base and the README on `develop` is empty. | High | Schedule / confusion | U4 `chore/m7-repo-hygiene`: hand-sync useful README text from `main` to `develop`, then set Gitea default to `develop` and reset/delete `main`. |
| R6 | **#16 scope creep**: Quartz / `ICCeryPrintKit` / TargetPrint work is mistaken for M7. | Medium | Schedule | Decision log D1; non-goals list; one-concern-per-branch rule. |
| R7 | **Force Quit orphans**: child processes or GPU context survive termination because a tester Force Quits while `killAll` is still running. | Low | P1 | S0 already hardened `ProcessManager` with `waitUntilExit()` fallbacks and a 2-second `forceKill`/`forceFinalize` watchdog; U5b adds a quit notice to reduce the window. |
| R8 | **U5 polish blocks M7 exit**: optional a11y / killAll / UI-test branches grow beyond ~4 files or get bundled with P0 work. | Low | Schedule | U5 is explicitly optional, split into three tiny branches, and landed last only if cheap. |
| R9 | **Merge order conflict**: U1 re-enables the workflow before #69 lands, causing a rename/rename conflict when #69 is merged. | Medium | CI blocked | Branch map enforces #69 before U1; #69 keeps `.gitea/workflows.disabled/macos.yml`, so U1 simply moves the file afterwards. |
| R10 | **Planning docs accidentally committed to `develop`**: `docs/megaplans/PREUAT-*.md` or `M7-*.md` are bundled into a product PR. | Low | Repo noise | Decision log D9; U4 explicitly excludes planning docs. |