diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..90fee7f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Sensitive SDLC, dependency, permission, signing, and release controls. +/.github/workflows/ @IchenDEV +/Resources/OpenType.entitlements @IchenDEV +/Package.swift @IchenDEV +/Package.resolved @IchenDEV +/scripts/build-app.sh @IchenDEV +/scripts/create-signing-cert.sh @IchenDEV +/scripts/verify-release-artifact.sh @IchenDEV +/scripts/sdlc.py @IchenDEV +/docs/sdlc/ @IchenDEV +/AGENTS.md @IchenDEV +/CLAUDE.md @IchenDEV diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b64b136 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/IchenDEV/utter/security/advisories/new + about: Report vulnerabilities privately; do not include secrets in a public issue. diff --git a/.github/ISSUE_TEMPLATE/incident.yml b/.github/ISSUE_TEMPLATE/incident.yml new file mode 100644 index 0000000..c599c97 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/incident.yml @@ -0,0 +1,58 @@ +name: Production incident +description: Record impact, containment, and the corrective feedback loop. +title: "[Incident]: " +labels: + - incident +body: + - type: dropdown + id: severity + attributes: + label: Severity + options: + - SEV-1 — widespread or critical safety/security impact + - SEV-2 — major feature unavailable or serious degradation + - SEV-3 — limited degradation with a workaround + validations: + required: true + - type: textarea + id: impact + attributes: + label: Impact + description: Include affected users/systems, duration, and known data or privacy impact. + validations: + required: true + - type: textarea + id: detection + attributes: + label: Detection signal and timeline + description: State the deterministic signal when available and list key events with times. + validations: + required: true + - type: textarea + id: containment + attributes: + label: Containment and current state + description: Record actions, approval boundaries, and whether the incident is still active. + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Cause evidence and unknowns + description: Separate confirmed facts from hypotheses and unknowns. + validations: + required: true + - type: textarea + id: corrective_intent + attributes: + label: Corrective intent + description: Link the follow-up intent or change bundle and name its owner. + validations: + required: true + - type: textarea + id: regression_control + attributes: + label: Regression control + description: Name the new test, guardrail, eval case, or why automation is not possible. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/intent.yml b/.github/ISSUE_TEMPLATE/intent.yml new file mode 100644 index 0000000..a0ac997 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/intent.yml @@ -0,0 +1,58 @@ +name: Product or engineering intent +description: Propose an observable outcome before implementation starts. +title: "[Intent]: " +labels: + - intent +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What is happening now, and what evidence shows it is a problem? + validations: + required: true + - type: textarea + id: outcome + attributes: + label: Desired outcome + description: Describe the user or system result, not the implementation. + validations: + required: true + - type: textarea + id: affected + attributes: + label: Affected users and systems + validations: + required: true + - type: textarea + id: constraints + attributes: + label: Constraints and non-goals + description: Include privacy, permissions, compatibility, cost, schedule, and explicit exclusions. + validations: + required: true + - type: textarea + id: acceptance + attributes: + label: Acceptance criteria + description: Use observable, testable criteria. + placeholder: "- When ..., then ..." + validations: + required: true + - type: textarea + id: open_questions + attributes: + label: Open questions + description: Record unresolved decisions or write None. + validations: + required: true + - type: dropdown + id: risk + attributes: + label: Initial risk + options: + - Low — isolated and reversible + - Medium — user-visible, dependency, model, UI, or automation change + - High — privacy, permission, security, release, destructive, or public API change + validations: + required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c9cc9b6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +## Outcome + + + +## SDLC bundle and risk + +- Bundle: `docs/sdlc/changes/...` +- Risk: trivial / low / medium / high +- Human decisions still required: + +## Verification + + + +- [ ] `python3 scripts/sdlc.py validate --worktree` +- [ ] `bash scripts/ci-basic-checks.sh` +- [ ] `swift test` +- [ ] Release-style app build when packaging/runtime is affected +- [ ] Real-window light/dark and narrow-width QA when macOS UI is affected +- [ ] Privacy, permission, external-service, and failure paths checked when affected + +## Residual risk and rollback + + + +## Reviewer focus + + diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b8b53fd..fb338ce 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -11,22 +11,52 @@ permissions: contents: read concurrency: - group: pr-${{ github.ref }} + group: pr-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - basic-checks: - name: Basic linked rules + quality: + name: Contract & Tests runs-on: macos-26 + timeout-minutes: 45 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Run basic linked rules + - name: Validate changed SDLC artifacts + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + BEFORE_SHA: ${{ github.event.before }} + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + python3 scripts/sdlc.py validate --base "origin/$BASE_REF" + elif [ "$EVENT_NAME" = "push" ]; then + if [ -z "$BEFORE_SHA" ] || [[ "$BEFORE_SHA" =~ ^0+$ ]]; then + echo "Push event has no usable previous SHA; refusing schema-only validation" + exit 1 + fi + git fetch --no-tags origin "$BEFORE_SHA" + git cat-file -e "$BEFORE_SHA^{commit}" + python3 scripts/sdlc.py validate --push-base "$BEFORE_SHA" + else + python3 scripts/sdlc.py validate + fi + + - name: Run linked repository checks run: bash ./scripts/ci-basic-checks.sh + - name: Ensure Metal toolchain + run: xcodebuild -downloadComponent MetalToolchain + + - name: Run unit tests + run: swift test + build: - name: Build Utter + name: Release-style App Build runs-on: macos-26 + timeout-minutes: 60 steps: - uses: actions/checkout@v4 @@ -36,5 +66,22 @@ jobs: - name: Ensure Metal toolchain run: xcodebuild -downloadComponent MetalToolchain - - name: Build app bundle + - name: Build and verify app bundle run: ./scripts/build-app.sh --app-only --sign=- + + gate: + name: SDLC Gate + if: ${{ always() }} + needs: [quality, build] + runs-on: ubuntu-latest + steps: + - name: Require every SDLC job + env: + QUALITY_RESULT: ${{ needs.quality.result }} + BUILD_RESULT: ${{ needs.build.result }} + run: | + if [ "$QUALITY_RESULT" != "success" ] || [ "$BUILD_RESULT" != "success" ]; then + echo "Contract/tests: $QUALITY_RESULT" + echo "App build: $BUILD_RESULT" + exit 1 + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a698130..b73b5d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,32 +6,70 @@ on: - "v*" permissions: - contents: write + contents: read jobs: - build: - name: Build & Release + validate: + name: Release Candidate Tests runs-on: macos-26 + timeout-minutes: 45 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Require a SemVer tag on main + run: | + ./scripts/release-version.sh "$GITHUB_REF_NAME" >/dev/null + git fetch origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "Release commit $GITHUB_SHA is not on origin/main" + exit 1 + fi + + - name: Run repository checks + run: bash ./scripts/ci-basic-checks.sh + + - name: Ensure Metal toolchain + run: xcodebuild -downloadComponent MetalToolchain + + - name: Run unit tests + run: swift test + + release: + name: Sign, Verify & Publish + needs: validate + runs-on: macos-26 + timeout-minutes: 75 + environment: production + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Resolve version from tag id: version - run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + run: echo "value=$(./scripts/release-version.sh "$GITHUB_REF_NAME")" >> "$GITHUB_OUTPUT" - - name: Check secrets availability - id: secrets-check + - name: Require release credentials + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} run: | - if [ "${{ secrets.APPLE_CERTIFICATE_P12 }}" != '' ]; then - echo "has-cert=true" >> "$GITHUB_OUTPUT" - fi - if [ "${{ secrets.APPLE_ID }}" != '' ] && [ "${{ secrets.APPLE_TEAM_ID }}" != '' ]; then - echo "has-notarize=true" >> "$GITHUB_OUTPUT" + missing=() + for name in APPLE_CERTIFICATE_P12 APPLE_CERTIFICATE_PASSWORD; do + if [ -z "${!name:-}" ]; then + missing+=("$name") + fi + done + if [ ${#missing[@]} -ne 0 ]; then + echo "Missing protected release secrets: ${missing[*]}" + exit 1 fi - # ── Import signing certificate ────────────────────────────── - - name: Import code-signing certificate - if: steps.secrets-check.outputs.has-cert == 'true' + - name: Import signing certificate env: APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -41,7 +79,6 @@ jobs: KEYCHAIN_PASS="$(openssl rand -hex 16)" echo "$APPLE_CERTIFICATE_P12" | base64 --decode > "$CERT_PATH" - security create-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" @@ -50,52 +87,82 @@ jobs: security set-key-partition-list -S apple-tool:,apple: \ -k "$KEYCHAIN_PASS" "$KEYCHAIN_PATH" - # For self-signed certs: extract PEM and add to trust store - CERT_PEM="$RUNNER_TEMP/cert.pem" + CERT_PEM="$RUNNER_TEMP/certificate.pem" openssl pkcs12 -in "$CERT_PATH" -clcerts -nokeys \ - -passin "pass:${APPLE_CERTIFICATE_PASSWORD}" -out "$CERT_PEM" 2>/dev/null || true - if [ -f "$CERT_PEM" ]; then + -passin "pass:${APPLE_CERTIFICATE_PASSWORD}" -out "$CERT_PEM" + SIGN_CERT_SHA256="$(openssl x509 -in "$CERT_PEM" -noout \ + -fingerprint -sha256 | cut -d= -f2 | tr -d ':')" + IDENTITY="$(security find-identity -p codesigning "$KEYCHAIN_PATH" \ + | sed -n 's/.*"\(.*\)".*/\1/p' \ + | head -1)" + if [ -z "$IDENTITY" ]; then + echo "No code-signing identity found in the release certificate" + exit 1 + fi + if [[ "$IDENTITY" != "Developer ID Application:"* ]]; then sudo security add-trusted-cert -d -r trustRoot \ - -k "$KEYCHAIN_PATH" "$CERT_PEM" 2>/dev/null || true - rm -f "$CERT_PEM" + -k "$KEYCHAIN_PATH" "$CERT_PEM" fi - security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db - rm -f "$CERT_PATH" + rm -f "$CERT_PATH" "$CERT_PEM" + echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "SIGN_CERT_SHA256=$SIGN_CERT_SHA256" >> "$GITHUB_ENV" + echo "Signing identity imported: $IDENTITY" - # Detect the imported identity (without -v so self-signed certs are included) - IDENTITY=$(security find-identity -p codesigning "$KEYCHAIN_PATH" \ - | grep '"' | head -1 | sed 's/.*"\(.*\)".*/\1/') + - name: Ensure Metal toolchain + run: xcodebuild -downloadComponent MetalToolchain - if [ -n "$IDENTITY" ]; then - echo "SIGN_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" - echo "✓ Signing identity: $IDENTITY" - else - echo "⚠ No codesigning identity found after import, will use ad-hoc" - fi + - name: Build signed app and DMG + run: | + ./scripts/build-app.sh \ + --version="${{ steps.version.outputs.value }}" \ + --sign="$SIGN_IDENTITY" - # ── Build ─────────────────────────────────────────────────── - - name: Build .app and .dmg + - name: Classify and verify signed artifact run: | - ARGS=(--version="${{ steps.version.outputs.value }}") - if [ -n "${SIGN_IDENTITY:-}" ]; then - ARGS+=(--sign="${SIGN_IDENTITY}") + SIGNATURE="$(codesign -dvvv dist/Utter.app 2>&1)" + if ! grep -Fqx "Authority=$SIGN_IDENTITY" <<<"$SIGNATURE"; then + echo "Built app authority does not match imported identity: $SIGN_IDENTITY" + exit 1 fi - ./scripts/build-app.sh "${ARGS[@]}" - - name: Verify artifacts - run: | - ls -lh dist/ - codesign -dvv dist/Utter.app + VERIFY_ARGS=( + --app dist/Utter.app + --dmg "dist/Utter-${{ steps.version.outputs.value }}.dmg" + --version "${{ steps.version.outputs.value }}" + --expected-cert-sha256 "$SIGN_CERT_SHA256" + ) + if grep -q '^Authority=Developer ID Application:' <<<"$SIGNATURE"; then + SIGNING_MODE=developer-id + VERIFY_ARGS+=(--require-developer-id) + elif grep -q '^TeamIdentifier=not set$' <<<"$SIGNATURE"; then + SIGNING_MODE=self-signed + VERIFY_ARGS+=(--require-self-signed) + else + echo "Unsupported non-Developer-ID signing identity" + exit 1 + fi + echo "SIGNING_MODE=$SIGNING_MODE" >> "$GITHUB_ENV" + echo "Signing mode: $SIGNING_MODE" | tee -a "$GITHUB_STEP_SUMMARY" + ./scripts/verify-release-artifact.sh "${VERIFY_ARGS[@]}" - # ── Notarize (Developer ID only) ─────────────────────────── - - name: Notarize DMG - if: steps.secrets-check.outputs.has-notarize == 'true' + - name: Notarize and staple DMG + if: env.SIGNING_MODE == 'developer-id' env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} run: | + missing=() + for name in APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD; do + if [ -z "${!name:-}" ]; then + missing+=("$name") + fi + done + if [ ${#missing[@]} -ne 0 ]; then + echo "Developer ID release is missing notarization secrets: ${missing[*]}" + exit 1 + fi DMG="dist/Utter-${{ steps.version.outputs.value }}.dmg" xcrun notarytool submit "$DMG" \ --apple-id "$APPLE_ID" \ @@ -104,31 +171,61 @@ jobs: --wait --timeout 30m xcrun stapler staple "$DMG" - # ── Release ───────────────────────────────────────────────── + - name: Verify distribution and checksum + run: | + DMG="dist/Utter-${{ steps.version.outputs.value }}.dmg" + VERIFY_ARGS=( + --app dist/Utter.app + --dmg "$DMG" + --version "${{ steps.version.outputs.value }}" + --expected-cert-sha256 "$SIGN_CERT_SHA256" + ) + if [ "$SIGNING_MODE" = "developer-id" ]; then + VERIFY_ARGS+=(--require-developer-id --require-notarization) + else + VERIFY_ARGS+=(--require-self-signed) + fi + ./scripts/verify-release-artifact.sh "${VERIFY_ARGS[@]}" + ( + cd dist + shasum -a 256 "$(basename "$DMG")" > "$(basename "$DMG").sha256" + shasum -c "$(basename "$DMG").sha256" + ) + - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - shopt -s nullglob - - TAG="${GITHUB_REF_NAME}" - TITLE="Utter ${TAG}" - FILES=(dist/Utter-*.dmg) - - if [ ${#FILES[@]} -eq 0 ]; then - echo "No DMG artifacts found in dist/" + TAG="$GITHUB_REF_NAME" + DMG="dist/Utter-${{ steps.version.outputs.value }}.dmg" + CHECKSUM="$DMG.sha256" + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists; refusing to replace immutable assets" exit 1 fi - - if gh release view "$TAG" >/dev/null 2>&1; then - echo "Release $TAG already exists, uploading assets..." + if [ "$SIGNING_MODE" = "self-signed" ]; then + RELEASE_NOTE=$'> [!WARNING]\n> This release is signed with the project self-signed certificate and is not Apple-notarized. macOS may require manual approval before opening it.' else - echo "Creating release $TAG..." - gh release create "$TAG" \ - --title "$TITLE" \ - --generate-notes \ - --verify-tag + RELEASE_NOTE=$'> [!NOTE]\n> This release is signed with Apple Developer ID and notarized by Apple.' fi - - gh release upload "$TAG" "${FILES[@]}" --clobber + gh release create "$TAG" \ + --draft \ + --title "Utter $TAG" \ + --generate-notes \ + --notes "$RELEASE_NOTE" \ + --verify-tag + gh release upload "$TAG" "$DMG" "$CHECKSUM" + + DOWNLOAD_DIR="$(mktemp -d)" + trap 'rm -r "$DOWNLOAD_DIR"' EXIT + gh release download "$TAG" \ + --pattern "$(basename "$DMG")" \ + --pattern "$(basename "$CHECKSUM")" \ + --dir "$DOWNLOAD_DIR" + ( + cd "$DOWNLOAD_DIR" + shasum -c "$(basename "$CHECKSUM")" + ) + cmp "$DMG" "$DOWNLOAD_DIR/$(basename "$DMG")" + gh release edit "$TAG" --draft=false --latest diff --git a/.gitignore b/.gitignore index f5ad325..f76a7e7 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,8 @@ dist/ *.o *.d *.hmap +__pycache__/ +*.pyc *.ipa *.dSYM *.dSYM.zip @@ -90,7 +92,6 @@ playground.xcworkspace # =========================== # AI Tool Config # =========================== -CLAUDE.md .worktrees/ # =========================== diff --git a/AGENTS.md b/AGENTS.md index 1e93ad4..e6228ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,9 +41,21 @@ Utter is a macOS menu bar voice input app built with Swift 6 / SwiftUI / AppKit. - **Dev build**: `swift build`, `bash scripts/build-and-run.sh --verify`, or open `Package.swift` in Xcode - **Release build**: `bash scripts/build-app.sh` — uses `xcodebuild` (required for Metal shader bundling), then assembles .app and .dmg -- **CI**: `.github/workflows/release.yml` — builds on macOS, signs, optionally notarizes, publishes to GitHub Releases +- **PR CI**: `.github/workflows/pr.yml` — validates SDLC artifacts, runs linked checks and unit tests, builds a release-style app, and exposes the stable `SDLC Gate` check +- **Release CI**: `.github/workflows/release.yml` — accepts a SemVer tag on `main`, verifies either the existing self-signed identity or Developer ID, requires Apple notarization for Developer ID, and publishes the mounted-DMG-verified artifact with a checksum - **Icon**: `scripts/generate-icon.swift` programmatically renders the icon and generates `.icns`; `Sources/App/AppIcon.swift` renders the same icon at runtime for the Dock +## SDLC Operating Contract + +- Read `docs/sdlc/README.md` before non-trivial implementation, automation, test, UI, dependency, permission, or release changes. +- Create or update `docs/sdlc/changes//`. Low risk requires intent, plan, and verification; medium/high risk also requires a spec. +- Keep `state.json` honest. An agent may advance work to `verified` with current evidence, but may not record its own work as human approval. +- Begin implementation only after intent has observable acceptance criteria and medium/high-risk design choices have been reviewed. +- Always run `python3 scripts/sdlc.py validate --worktree`, `bash scripts/ci-basic-checks.sh`, and `swift test`. Add release-style build, real-window visual QA, permission/privacy paths, or clean-machine checks in proportion to risk. +- High-risk changes require an independent verifier, explicit rollback, PR approval, and protected production approval. +- A production incident must link a corrective intent and add a regression test, deterministic guardrail, eval case, or explicit reason automation is impossible. +- Never fall back to ad-hoc signing when configured signing fails. A self-signed release must use the configured identity, pass the same artifact/checksum checks, and be labeled as not Apple-notarized. + ## Coding Conventions - Swift 6 with `.swiftLanguageMode(.v5)` for compatibility diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index 01d2676..5d5f073 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,15 @@ bash scripts/build-and-run.sh --verify open Package.swift ``` +Material changes follow the artifact-driven workflow in +[`docs/sdlc/README.md`](docs/sdlc/README.md). Before opening a pull request, run: + +```bash +python3 scripts/sdlc.py validate --worktree +bash scripts/ci-basic-checks.sh +swift test +``` + The public app is `Utter.app`. The Swift package product remains `OpenType` so existing source integrations and upgrade paths continue to work. ## First Run diff --git a/README_zh.md b/README_zh.md index 91aba89..1d6f47a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -92,6 +92,15 @@ bash scripts/build-and-run.sh --verify open Package.swift ``` +实质性改动遵循 [`docs/sdlc/README.md`](docs/sdlc/README.md) 中的 Artifact 驱动流程。 +提交 Pull Request 前请运行: + +```bash +python3 scripts/sdlc.py validate --worktree +bash scripts/ci-basic-checks.sh +swift test +``` + 对外应用名为 `Utter.app`;Swift 包产物暂时保留 `OpenType`,以兼容现有源码集成和升级路径。 ## 首次使用 diff --git a/docs/research/ai-native-sdlc-utter-2026-08-25.md b/docs/research/ai-native-sdlc-utter-2026-08-25.md new file mode 100644 index 0000000..c30a50d --- /dev/null +++ b/docs/research/ai-native-sdlc-utter-2026-08-25.md @@ -0,0 +1,485 @@ +# Anthropic AI-Native SDLC 原文核验与 Utter 改造蓝图 + +日期:2026-08-25 + +范围:Anthropic/Claude 一手资料;Utter 改造前基线 commit `c5ee6a6525aae820329e034e64cbe835f1232712`;面向 macOS 26、Swift 6、SwiftUI/AppKit、Apple Silicon 的工程流程。 + +不在范围:本报告不证明某个 AI 工具在 Utter 上已经达到自治运行,也不把 Anthropic 的示例配置当成已部署产品。 + +基线说明:仓库事实均以该 immutable commit 为准,并链接到对应 GitHub permalink;同一工作树内随后发生的 SDLC 实施改动不倒灌进“改造前缺口”。 + +> 证据标记:**[事实]** 可由一手网页或当前仓库直接复核;**[推断]** 是面向 Utter 的工程判断;**[未确认]** 需要实际运行、组织选择或外部系统状态才能回答。 + +## 结论 + +用户提供的原文信息没有标题、日期或作者错误。官方页面标题确为 **The AI-Native SDLC playbook**,日期为 **August 21, 2026**,作者为 **Louis Claxton**;页面归类在 Enterprise AI / Claude Code,并把 Claude Enterprise、Claude Code、Claude Tag 列为相关产品。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook) + +**[事实]** 原文真正提出的是一条受人工关口约束的 Artifact Chain:`intent.md → spec.md → plan.md → diff + tests → PR + review findings → incident record → 新 intent.md`。接受一个 Artifact 可以启动下一段;人继续对所有需要判断的决定负责,提交历史和 PR 历史共同组成审计线索。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook) + +**[推断]** Utter 不应先追求“无人值守自动写代码”,而应先修复 Agent 加速后会放大的四个运行系统缺口: + +1. 改造前的新工作缺少统一的 `intent → spec → plan → verification` 关联; +2. 改造前 PR CI 会构建 App,却没有执行 Swift 测试套件; +3. 原生 macOS UI、TCC 权限、模型质量和发布产物没有形成统一的可提交验证证据; +4. 改造前 tag 流程允许在签名或公证条件缺失时继续走向公开 Release,生产关口不够硬。 + +**[推断]** 推荐的终态不是把所有判断交给 Agent,而是:Agent 可以一直执行到明确的关口,确定性脚本和 CI 负责必须成立的规则,人审核意图、风险和发布授权。第一轮应当手动推进 Artifact;只有当门禁、Eval 和回滚都成熟后,才自动触发下一阶段。 + +## 一手原文核验 + +### 元数据与来源性质 + +- **[事实]** 标题、日期、作者与用户粘贴文一致;带有 `utm_source` 的链接只是跟踪参数,规范链接是 [`https://claude.com/blog/the-ai-native-sdlc-playbook`](https://claude.com/blog/the-ai-native-sdlc-playbook)。 +- **[事实]** 原文说,这份指南介绍 Applied AI 团队将 Claude 融入 SDLC 的实践,受到客户工作的启发;结尾进一步说,它汇总了 Applied AI 团队每天为客户执行的许多真实最佳实践。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook) +- **[边界]** 这能证明方法来自 Anthropic Applied AI 的客户实践,不能证明六个阶段的每个 Play 已经在 Anthropic 自身或每个客户处完整上线,也不能把文中的预期改进当成公开测量结果。 +- **[边界]** “Code is no longer the bottleneck”是文章的核心判断和标题段落,不是一项在文中附带样本、基线与统计方法的行业研究结论。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook) + +### 用户粘贴文中的关键主张 + +| 主张 | 核验结果 | 精确边界 | +| --- | --- | --- | +| 六阶段为 Plan、Design、Build、Test、Deploy、Maintain | **[事实] 已验证** | 原文称它们是 non-linear stages,不应重新解释成固定瀑布流程。[原文](https://claude.com/blog/the-ai-native-sdlc-playbook) | +| 接受 Artifact 会触发下一阶段 | **[事实] 已验证** | 原文明说:接受 `intent.md` 启动设计,批准 `spec.md` 启动 plan mode,合并 PR 启动 pipeline,生产控制带越界写回新的 `intent.md`;同时建议先人工 prompt,终态才是自动触发。[原文](https://claude.com/blog/the-ai-native-sdlc-playbook) | +| `intent.md` 包含 problem、outcome、users/systems、constraints、open questions | **[事实] 已验证** | 原文给出这些建议字段,并要求 Product Owner 修正 Agent 的理解后才能提交。[原文](https://claude.com/blog/the-ai-native-sdlc-playbook) | +| `CLAUDE.md` 放项目上下文,Skill 放重复执行的组织知识 | **[事实] 已验证** | 官方文档也把 `CLAUDE.md` 定义为项目级共享上下文,把 Skill 定义为带触发描述的可复用知识/工作流。[原文](https://claude.com/blog/the-ai-native-sdlc-playbook)、[Claude Code memory](https://code.claude.com/docs/en/memory)、[Skills](https://code.claude.com/docs/en/skills) | +| Skill 是 advisory,Hook 是 deterministic layer | **[事实] 基本验证,但需收紧** | 原文确实这样表述;当前 Hook 文档还支持 prompt-based 和 agent-based hooks,因此严格的确定性门禁应采用 command/HTTP 规则、权限、sandbox、CI 和 branch protection,而不是泛指所有 Hook。[原文](https://claude.com/blog/the-ai-native-sdlc-playbook)、[Hooks guide](https://code.claude.com/docs/en/hooks-guide)、[Hooks reference](https://code.claude.com/docs/en/hooks) | +| Agent 需要 Build/Test/Screenshot 自反馈,UI 通常迭代两三轮 | **[事实] 已验证** | 原文区分了贯穿任务的 self-feedback loop 与最后在新 Context 中做结论的 verifier subagent。[原文](https://claude.com/blog/the-ai-native-sdlc-playbook)、[Subagents](https://code.claude.com/docs/en/sub-agents) | +| Verifier Agent 是独立产品 | **[事实] 原文不支持此强表述** | 原文提供的是自定义 `.claude/agents/verifier.md` 模式和示例;它不是一项单独列出的内置托管服务。[原文](https://claude.com/blog/the-ai-native-sdlc-playbook) | +| Continuous Evals 从 20–50 个真实任务起步,事故变 Eval | **[事实] 已验证** | 原文给出自建 CI 示例:真实任务 + 接受检查;在 `CLAUDE.md`、Skill、Hook 等配置变化和定时任务上运行;生产事故成为长期回归项。它不是现成托管 Eval 产品。[原文](https://claude.com/blog/the-ai-native-sdlc-playbook) | +| 1σ 记录、2σ 只读诊断、3σ 可动作 | **[事实] 已验证,但需收紧** | 确定性、版本化并有单测的检测脚本负责监视;Claude 越界后才被无状态唤起。3σ 也只能开 PR 进入审核或调用预批准 runbook,不代表自由改生产。[原文](https://claude.com/blog/the-ai-native-sdlc-playbook) | +| OpenTelemetry 就是完整运行历史 / 生产监控 | **[事实] 不成立** | 官方 OTel 文档覆盖 Claude Code 用量、成本、工具活动和审计事件;异常检测、基线、跨 Session 关联与告警由组织自己的后端负责。Artifact、Git/PR、CI 记录仍需各自保存。[Monitoring](https://code.claude.com/docs/en/monitoring-usage) | +| 非工程人员可以从 Claude/Cowork 写入 GitHub | **[事实] 原文支持,但连接器要分清** | 原文建议经版本控制 connector 提交 Markdown。当前 Read & write 的 GitHub MCP connector 可以承担写入;旧 GitHub 内容集成文档描述的是只读文件与分支内容。[原文](https://claude.com/blog/the-ai-native-sdlc-playbook)、[GitHub MCP connector](https://claude.com/connectors/github)、[旧 GitHub integration](https://claude.com/docs/connectors/github) | + +### 原文没有证明的扩展判断 + +- **[推断]** “Anthropic 正在争夺软件公司的工作运行层”“它已经是通用 Personal Harness”是有依据的产品方向解读,但不是原文中的可验证事实。 +- **[未确认]** 粘贴文中的 Reddit 团队案例没有给出可核验的一手链接;它不应作为本项目改造的证据或指标基线。 +- **[推断]** DeepSeek、Vercel 或其他 Harness 与本 Playbook 指向同一趋势,需要分别研究各家一手资料;不能由这篇 Anthropic 文章单独推出。 +- **[事实]** 原文自己的最强治理边界是“Agent 做到生产关口为止,但不能越过关口”,并要求 branch protection、权限分层、sandbox、scoped credentials、人工发布授权和预先演练的 rollback。[原文](https://claude.com/blog/the-ai-native-sdlc-playbook)、[Permissions](https://code.claude.com/docs/en/permissions)、[Sandboxing](https://code.claude.com/docs/en/sandboxing) + +## Utter 改造前运行系统审计 + +### 已经具备的基础 + +- **[事实]** 根目录 [`AGENTS.md`][baseline-agents] 已描述产品、模块、Swift 并发和本地化约定、构建/发布命令以及 Metal、TCC、Screen Recording、Apple Speech 的常见错误。这已经承担了 Playbook 中“新工程师需要知道什么”的大部分项目上下文职责。 +- **[事实]** 基线已有分开的设计和实施文档,例如 [`docs/superpowers/specs`][baseline-specs] 与 [`docs/superpowers/plans`][baseline-plans],说明 `spec` / `plan` Artifact 不是从零开始;但这些历史文件没有统一的 `work_id`、前置 `intent`、风险级别、审批人和最终 evidence 合约。 +- **[事实]** [`scripts/ci-basic-checks.sh`][baseline-basic-checks] 已把 Info.plist、entitlements、本地化 key parity、资源、品牌兼容标识、冲突标记和坏 symlink 做成确定性检查;[`scripts/unit-test-coverage.sh`][baseline-unit-coverage] 已能运行 `swift test --enable-code-coverage` 并检查部分核心文件的覆盖率。 +- **[事实]** [`scripts/build-app.sh`][baseline-build-app] 通过 `xcodebuild` 生成 arm64 Release App,复制 MLX 资源 bundle、编译 Icon Composer 资源,并签名/组装 DMG;这符合 [`AGENTS.md`][baseline-agents] 记录的“release 不能只用 bare `swift build`,否则 Metal shader 不完整”的项目约束。 +- **[事实]** 语音质量已经有机器可读的 JSONL schema 和 CER/WER、术语、静音幻觉、数字/URL/email/path 保真与延迟指标实现,可作为产品 Eval 的起点。[`evaluate-voice-quality.py`][baseline-voice-eval]、[示例 corpus][baseline-voice-corpus]、[现有质量研究][baseline-voice-research] + +### 会被 Agent 产出速度放大的缺口 + +- **[事实]** 基线 PR workflow 只执行 basic linked rules 和 `build-app.sh --app-only --sign=-`,没有调用 `swift test` 或 coverage script。[`.github/workflows/pr.yml`][baseline-pr-workflow] +- **[事实]** `build-and-run.sh --verify` 的验收条件只是打开 App 后等待同名进程出现,最多约 5 秒;它能证明进程存活,不能证明菜单栏、录音、转写、文本插入、TCC 或 UI 状态正确。[`scripts/build-and-run.sh`][baseline-build-run] +- **[事实]** 基线 PR build 生成 ad-hoc signed App 后即结束,没有把 `codesign --verify --deep --strict`、架构、`default.metallib`、关键资源或可启动性作为独立 required check。[`.github/workflows/pr.yml`][baseline-pr-workflow]、[`scripts/build-app.sh`][baseline-build-app] +- **[事实]** 基线 tag release 中,签名证书缺失时会回退到 ad-hoc;公证凭据缺失时跳过 notarize;随后仍可创建并上传公开 GitHub Release。`Verify artifacts` 只运行展示签名信息的 `codesign -dvv`,不是严格签名验证。[`.github/workflows/release.yml`][baseline-release-workflow] +- **[事实]** 基线 entitlements 涉及麦克风、Speech Recognition、Screen Recording、Apple Events Automation、JIT/unsigned executable memory/library validation,并关闭 App Sandbox;这些都是应当提升到高风险审批和真机验证的发布面。[`Resources/OpenType.entitlements`][baseline-entitlements] +- **[事实]** 基线 `.claude/settings.local.json` 只允许 `WebSearch`,没有项目级 Skill、Hook、sandbox 或审批门禁;同时仓库以 `AGENTS.md` 而非 `CLAUDE.md` 为共享 Agent 指令源。[`.claude/settings.local.json`][baseline-claude-settings]、[`AGENTS.md`][baseline-agents] +- **[边界]** 这不表示必须绑定 Claude。Claude 官方文档明确说 Claude Code 读取 `CLAUDE.md` 而不是 `AGENTS.md`,并建议让一个很薄的 `CLAUDE.md` 导入 `AGENTS.md`;Utter 可以继续把 `AGENTS.md` 作为跨 Agent 的唯一项目上下文源,只为特定工具增加适配层。[Claude Code memory: AGENTS.md](https://code.claude.com/docs/en/memory#agentsmd) + +## 目标 SDLC:一个 Artifact 驱动、证据驱动、风险分级的循环 + +```text +Issue / user request / incident + ↓ + intent.md (draft) + ↓ Product gate + spec.md (draft) + ↓ Product + risk gate + plan.md (draft) + ↓ Engineering gate + isolated implementation worktree + ↓ + code + tests + verification evidence + ↓ + PR + agent review + ↓ Code owner / human risk gate + signed release candidate + ↓ Release manager gate + release + ↓ + deterministic monitoring / incident + ↓ + incident.md + new intent.md + new eval + ↺ +``` + +**[推断]** “全面改造”应当改变状态和责任,而不只是增加文档模板: + +- Artifact 是阶段输入、人工审核对象、Agent 执行输入和审计记录; +- CI/脚本根据 Artifact 字段决定必须跑哪些证据; +- Agent 可以创建草稿、实现、修复、复核,但不能批准自己的 Artifact 或生产发布; +- 高风险路径不因测试通过而自动降级; +- 事故同时更新代码、Eval 和项目知识,避免只修一次。 + +## Artifact 合约 + +### 新工作包 + +**[推断]** 新工作统一放在 `docs/sdlc/changes//`,同一目录保存完整语义链,避免在 ticket、聊天和多个文档目录间丢失关联: + +```text +docs/sdlc/changes/2026-08-25-native-ui-verification/ +├── state.json +├── intent.md +├── spec.md +├── plan.md +├── verification.md +└── incident.md # 仅事故来源或发布后事故时存在 +``` + +现有 `docs/superpowers/specs/` 和 `docs/superpowers/plans/` 不应批量移动或重写历史;新改动引用旧文件的 commit permalink,旧工作只有在重新进入开发时才补 `work_id` 和新 work package。 + +### `state.json` + +**[推断]** 机器状态集中放在一个 JSON 文件,Markdown 负责可读内容,避免五个 frontmatter 互相漂移: + +```json +{ + "schemaVersion": 1, + "id": "2026-08-25-native-ui-verification", + "title": "Verify native macOS UI changes", + "risk": "medium", + "status": "planned", + "owners": ["github-handle"], + "acceptanceCriteria": ["Observable criterion"], + "artifacts": { + "intent": "intent.md", + "spec": "spec.md", + "plan": "plan.md", + "verification": "verification.md" + } +} +``` + +**[推断]** 状态沿 `intent → designed → planned → implementing → verified → released → closed` 前进。CI 只验证 schema、路径、必需章节、风险对应的 Artifact 和允许的状态转换,不让 LLM 决定内容是否“足够好”。人类 acceptance 仍由 PR review、branch protection 和 protected environment 记录,Agent 不得把修改 JSON 当成人工批准。 + +### `intent.md` + +必须让不读聊天的人理解: + +- Problem 与证据; +- Proposed outcome; +- Affected users / systems; +- Constraints 与明确的 out of scope; +- Success metrics; +- Open questions; +- 数据、隐私、TCC、签名、分发影响初筛。 + +这与 Anthropic 的 proto-spec 字段一致,但增加了 Utter 必需的隐私/TCC/分发影响。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook) + +### `spec.md` + +必须回答: + +- 用户流程与失败路径; +- 对 `App/Audio/Config/Hotkey/LLM/Output/Processing/Prompts/Screen/Speech/UI` 的影响; +- 状态、并发、数据生命周期和恢复策略; +- 中英文用户可见文案; +- 权限、隐私、网络、模型、签名与升级兼容性; +- 可测试的 acceptance criteria; +- 尚未解决的 concern 及其 policy owner。 + +### `plan.md` + +必须回答: + +- 要修改和明确不修改的文件; +- 步骤顺序与每步回滚点; +- 最危险步骤和备选方案; +- 测试、构建、原生 UI、TCC、模型、产物与发布证据矩阵; +- 并行任务的文件所有权;共享文件必须串行; +- 偏离 plan 时如何在同一 PR 内更新并重新审批。 + +Anthropic 也要求 plan 写明文件、顺序、风险和 proof,并在实现偏离时同步更新。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook) + +### `verification.md` + +**[推断]** 这是 Utter 改造前缺少的关键 Artifact。它只能记录实际执行结果,不能写“预计通过”: + +- commit SHA、macOS/Xcode/Swift 版本; +- 每个验证命令、exit code、通过/失败/跳过数量; +- skipped integration test 的原因和对应责任人; +- UI 截图或录屏的文件/PR artifact 链接、窗口状态、light/dark、viewport; +- TCC 测试所用签名身份类型和权限状态; +- 模型 ID/revision、语料版本、质量/延迟结果; +- App/DMG hash、架构、签名、公证、staple 和关键资源检查; +- 未验证事项和发布前必须完成的人工检查。 + +## 风险分级与人工关口 + +| 级别 | Utter 示例 | 必需 Artifact | Agent 自治边界与人工关口 | +| --- | --- | --- | --- | +| Trivial | 纯文案文档、注释、无行为 copy typo | PR 描述 | 可走快速路径;仓库规则需要时仍由人 review | +| Low | 隔离实现或测试变化,无隐私、安全、数据、发布或 UI 行为影响 | intent、plan、verification | 可在 plan 后实施、自验证、开 PR;至少 maintainer 合并 | +| Medium | 一般功能、设置项、UI、模型/runtime 行为、依赖、自动化 | Low + spec | 可实施并运行完整相关证据;人接受 intent/design 与 PR,不得自行接受视觉或产品语义 | +| High | 权限、隐私、安全边界、签名、release、破坏性迁移、公共 API 兼容 | 完整 bundle + 独立验证 + rollback | 只在隔离 worktree/受限凭据行动;指定 owner、独立 verifier、真机证据和 protected production approval | + +**[推断]** 公开发布始终是 High;“所有测试绿”不等于“可发布”。这与 Anthropic 的生产关口原则一致,也适配当前 App 的权限和签名面。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook)、[`OpenType.entitlements`][baseline-entitlements] + +## Agent Harness:建议、确定性规则和权限必须分层 + +### 1. 共享上下文 + +- `AGENTS.md` 继续是项目唯一事实源:架构、命令、约定、常见错误;删除陈旧或重复内容。 +- 如果团队使用 Claude Code,提交一个只导入 `AGENTS.md` 的薄 `CLAUDE.md`;不要复制两份项目规则。[Claude Code memory](https://code.claude.com/docs/en/memory#agentsmd) +- 当同一错误重复出现两次,把短而稳定的纠正写回 `AGENTS.md`;过程型长说明进入 Skill 或脚本,而不是无限增长 always-on context。这个节奏来自 Playbook 的建议,不是自动门禁。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook) + +### 2. 可复用 Skill + +**[推断]** 第一批只建立项目特有且反复发生的四类 Skill: + +1. `macos-native-ui-review`:真实 App、light/dark、初始/滚动、窄窗口、键盘与 VoiceOver 检查; +2. `tcc-and-signing-change`:麦克风、Speech、Screen Recording、Accessibility/Automation、稳定签名与 clean-user 验证; +3. `speech-quality-eval`:固定 corpus、模型 revision、CER/WER/实体保真/延迟和跳过边界; +4. `release-candidate-audit`:Xcode/Metal、bundle、架构、codesign、notarization、staple、DMG 安装与回滚演练。 + +Skill 只能提出做法;必须成立的检查由 `scripts/`、CI、branch protection 和环境保护规则执行。Anthropic 官方也把 Skill 定位为按描述触发的文件系统 Artifact,把 Hook/权限/sandbox 用作更强控制。[Skills](https://code.claude.com/docs/en/skills)、[Hooks guide](https://code.claude.com/docs/en/hooks-guide)、[Sandboxing](https://code.claude.com/docs/en/sandboxing) + +### 3. 确定性门禁 + +**[推断]** 项目脚本应当是 Agent 无关的控制层;任何 Claude/Codex Hook 只调用同一脚本: + +- 改动 `Resources/Info.plist` 或本地化任一语言时,强制 `ci-basic-checks.sh`; +- 改动 `Package.swift` / `Package.resolved` 时,强制依赖解析、许可证/来源差异和 Xcode app build; +- 改动 `Resources/OpenType.entitlements`、`.github/workflows/**`、`scripts/build-app.sh`、`scripts/create-signing-cert.sh` 时,要求 High 风险 Artifact 和 CODEOWNER; +- 防止凭据、模型权重、DMG、`.p12`、profile、日志和本机绝对路径进入 diff; +- Agent 不能直接 push `main`,不能创建/移动生产 tag,不能读取签名/公证 secret,不能把失败的 sandbox 命令改成无 sandbox 重试。 + +对 Claude Code,官方权限控制工具调用,sandbox 在 macOS 使用 Seatbelt 约束 Bash 子进程的文件和网络;二者是互补层,仍不能代替 CI 和 GitHub 分支保护。[Permissions](https://code.claude.com/docs/en/permissions)、[Sandboxing](https://code.claude.com/docs/en/sandboxing) + +## Build/Test:把“完成”改成可复核证据 + +### 单一入口与分层验证 + +**[推断]** 新增一个稳定入口,例如 `scripts/verify-change.sh`,由 Artifact 风险和改动路径选择层级;`AGENTS.md` 写清命令和健康输出: + +| 层级 | 触发 | 最低证据 | +| --- | --- | --- | +| Fast | 每次实现循环 | focused XCTest、`ci-basic-checks.sh`、`git diff --check` | +| Full | 所有 PR | `swift test`、basic checks、Xcode app build | +| Native UI | UI/权限/交互改动 | 已签名真实 App;light/dark;相关窗口初始/滚动/窄宽度;交互录屏或截图 | +| Model | ASR/LLM/Prompt/词典改动 | 固定 revision/corpus;质量、保真、延迟;offline/remote 边界;资源占用 | +| Release candidate | tag 前 | 严格 codesign、notarize/staple、bundle/Metal/arch/hash、DMG 安装、clean-user/TCC、rollback rehearsal | + +### 立即修复 PR CI 缺口 + +**[推断]** `.github/workflows/pr.yml` 至少增加: + +1. `swift test` required check; +2. 现有 basic linked rules; +3. 现有 Xcode/Metal App build; +4. 独立 artifact validation:`codesign --verify --deep --strict`、arm64、`default.metallib`、`Assets.car`、主 executable/CLI helper、Info.plist 与关键本地化资源; +5. SDLC `state.json` schema、Artifact path/heading、风险与状态 validator; +6. 对 UI/high-risk PR 输出待完成人工证据,而不是把未跑的真机检查伪装为通过。 + +基线 workflow 已有 2 和 3,缺 1、4、5、6。[`.github/workflows/pr.yml`][baseline-pr-workflow] + +### Bug fix 合约 + +**[推断]** Bug 先变成可失败的回归测试或确定性 reproducer,再实施修复: + +1. 在 `plan.md` 记录 reproduction; +2. 单独提交 failing regression test / fixture,并记录 commit; +3. 实现阶段不得修改该测试,除非人工批准并在 Artifact 解释原因; +4. 保存 red → green 命令输出; +5. verifier 在新 Context 只读复查改变行为和最近相邻流程,不负责修复。 + +这保留 Anthropic 所说的 self-feedback 与 fresh-context verifier 的区别。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook) + +### 原生 macOS UI 证据 + +**[推断]** 浏览器/component test 不能作为 SwiftUI/AppKit 视觉验收。UI PR 必须对实际 `.app` 验证: + +- 目标 macOS 版本与实际显示 scale; +- 系统 light/dark,必要时 high contrast / Reduce Transparency; +- 首次打开和滚动后的状态; +- 窄窗口与长本地化文本; +- menu bar、Settings、Onboarding、Overlay/HUD 的真实交互; +- TCC 未授权、拒绝、授权后、撤销后的状态; +- 截图标注 commit SHA 和构建签名。 + +基线 `build-and-run.sh --verify` 只能作为“App 进程启动”证据,不应更名解释成产品验证。[`scripts/build-and-run.sh`][baseline-build-run] + +## Continuous Evals:分别测试产品和 Agent 配置 + +### 产品 Eval + +**[推断]** 保留并扩展当前 voice-quality evaluator;将公开/合成小 corpus 放 CI,用户授权的真实语料只在受控本地或私有 runner 运行。Prompt、模型、词典、音频处理、streaming 或 cleanup 变化必须与固定基线对比,不能只证明“有输出”。现有 evaluator 已提供第一版 schema 和指标,不需要另起一套格式。[`evaluate-voice-quality.py`][baseline-voice-eval]、[质量研究][baseline-voice-research] + +### Agent/Harness Eval + +**[推断]** 建立与产品测试分离的 `agent-evals/`: + +- 从最近真实 PR / bug / review 中选择 20–50 个已脱敏任务; +- 每项包含 prompt/intent、固定 repo fixture 或 commit、允许工具、接受检查和禁止行为; +- 覆盖本地化双语、Swift concurrency、Prompt/JSON contract、TCC、Metal packaging、release signing、UI evidence、隐私/凭据等项目高风险类别; +- `AGENTS.md`、Skill、Hook/permission、review policy、Agent model/prompt 变化时运行; +- 关键安全/发布 case 必须 100% 通过;其余使用与基线相比不退化的门禁; +- 每次逃逸缺陷或事故在修复后增加永久 case。 + +20–50 个真实任务、配置变更触发和事故转 Eval 来自 Anthropic 原文;具体目录、类别和门槛是 Utter 的实施建议。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook) + +**[边界]** 不要一开始就在每个 PR 中运行昂贵的完整 Agent Eval。先在 nightly / 配置变更上跑,记录成本、方差和 flaky rate;在重复运行结果稳定后再设 required check。原文也允许某些团队按固定周期离线运行。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook) + +## Review:Agent 做一致性扫描,人判断意图与风险 + +**[推断]** 新增 review policy,至少分四个 pass: + +1. **Spec/plan compliance**:diff 是否解决 accepted intent,是否偏离 plan; +2. **Correctness**:状态、并发、失败恢复、边界、相邻回归; +3. **Privacy/security**:录音、屏幕、选择内容、历史、远程请求、日志、凭据、权限; +4. **macOS/release**:SwiftUI/AppKit 行为、TCC、签名、Metal、bundle、升级兼容。 + +Agent finding 必须有文件/行号、触发条件、影响和验证方法;style nit 限量。Agent 不批准自己的 PR,branch protection 仍要求 code owner。Anthropic 的 Code Review 也是 findings-only,不自动批准或阻断 PR,并支持 `REVIEW.md` 调整 review 行为。[Code Review](https://code.claude.com/docs/en/code-review) + +**[推断]** review 中第二次出现同类 Agent 错误时:短项目事实进入 `AGENTS.md`,可复用过程进入 Skill,必须成立的规则进入脚本/CI。不要把所有 review comment 都堆进 always-on instructions。 + +## Deploy:硬化生产关口和回滚 + +### Release candidate 与生产发布分离 + +**[推断]** tag 不应同时代表“开始构建”和“已获准公开发布”。推荐: + +1. 从已通过 required checks 的确定 commit 构建 release candidate; +2. 使用受保护 GitHub Environment,签名/公证 secret 只在该 job 注入; +3. 缺少 Developer ID 签名或完整公证凭据时 hard fail,禁止公开上传 ad-hoc DMG; +4. 严格验证 App 和 DMG,保存 hash、notary log、staple validation 和安装 smoke evidence; +5. release manager 审核 `verification.md` 后授权 publish; +6. Agent 可以生成 release notes、诊断失败、准备命令,不能创建 production tag 或越过 Environment approval。 + +这直接收紧基线 release workflow 的 optional signing/notarization 行为。[`.github/workflows/release.yml`][baseline-release-workflow] Anthropic 也要求 Agent 只能走到生产关口、生产凭据默认不常驻、rollback 预先演练。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook)、[GitHub Actions](https://code.claude.com/docs/en/github-actions) + +### 回滚不是一句“可以回退” + +**[推断]** 对 GitHub 分发的桌面 App,至少演练并记录: + +- 阻止有问题版本继续成为 latest; +- 恢复前一个已知良好 DMG 的可见性和下载链接; +- 对已经安装的用户给出不破坏历史/设置/模型缓存的降级说明; +- 如果将来有 auto-update,验证停止 rollout、channel pin 和 manifest rollback; +- 回滚后创建 `incident.md`、新 `intent.md` 和 Eval。 + +**[未确认]** 当前仓库快照没有在本报告中验证实际线上 GitHub Environment、branch protection、签名 secret、公证状态或未来 auto-update 服务;这些必须在实施发布门禁时查询实时 GitHub 配置。 + +## Maintain:先从 CI 和 Release 信号闭环,不拿用户隐私换自动化 + +**[推断]** Utter 是本地优先的桌面 App,不应为了套用服务器 5xx 示例而默认收集录音、屏幕、转写内容或输入历史。第一阶段只使用已有、低敏感的确定性信号: + +- PR/release workflow failure rate 与时长; +- test flaky rate、first-pass CI success; +- release candidate 签名/公证/安装失败; +- 已有 GitHub issue / crash report 的人工分类; +- 在明确 opt-in、数据最小化和隐私设计完成后,才考虑匿名 crash-free sessions、模型延迟或失败率。 + +### 第一版控制带 + +```yaml +metric: release_workflow_failure_rate +baseline: rolling_30d +detection: deterministic_versioned_script +tiers: + 1sigma: log + 2sigma: read_only_diagnosis + 3sigma: open_pull_request_or_preapproved_runbook +production_publish: human_only +``` + +**[推断]** 先把控制带用于 CI/release,而不是 App 用户数据:数据现成、可审计、误动作爆炸半径小。检测脚本必须有单测;Claude 只在越界后读取有限日志;3σ 默认开 PR,只有经过演练的 release rollback 才能成为 runbook。这个权限边界与原文一致。[Anthropic/Claude 原文](https://claude.com/blog/the-ai-native-sdlc-playbook) + +**[事实]** Claude Code 的 OTel 可以导出工具活动、权限决定、Hook 事件、用量和成本,但不会替 Utter 自动建立上述产品/发布控制带;检测和告警仍要自己实现。[Monitoring](https://code.claude.com/docs/en/monitoring-usage) + +## 分阶段实施顺序 + +### Phase 0:先冻结真实基线 + +- 记录当前 required checks、真实 `swift test`、Xcode App build、release artifact、签名/公证和真机 UI/TCC 现状; +- 不把历史报告中的测试数量或发布状态当作当前结果; +- 定义 Trivial/Low/Medium/High path owners 和唯一 production release manager。 + +退出条件:一份当前 commit 的 baseline `verification.md`,所有 skip/未确认项明确列出。 + +### Phase 1:Artifact 和 PR 门禁 + +- 引入 `docs/sdlc/changes//`、`state.json` 与风险分级模板; +- PR template 强制链接 state/intent/spec/plan/verification; +- 增加 state/schema/path/heading/governed-diff validator; +- PR CI 加 `swift test`、严格 App artifact validation; +- 给 entitlements、release workflow、Package 与隐私敏感路径加 CODEOWNER。 + +退出条件:一个中等风险真实改动从 intent 到 merge 完整跑通;旧 specs/plans 未被破坏。 + +### Phase 2:验证反馈回路 + +- 建立单一 `verify-change` 入口和 Fast/Full/UI/Model/Release 层级; +- UI verification evidence 走真实 macOS App; +- 回归 test red → green 合约; +- fresh-context verifier 只读复核; +- release workflow 缺签名/公证即 hard fail,并加入人工 Environment gate。 + +退出条件:Agent 无需人逐条提醒就能产生可复核 verification evidence,但仍不能批准或发布。 + +### Phase 3:配置即代码与 Continuous Evals + +- 收敛 `AGENTS.md`,按重复问题建立四个 Skill; +- 确定性规则进入脚本/CI,工具适配层只调用它们; +- 收集 20–50 个 Agent Eval; +- 配置变更和 nightly 运行,记录成本、方差、flaky rate; +- review finding 和 incident 形成知识/Skill/门禁/Eval 的分流。 + +退出条件:改变 Agent 配置时可以用真实任务回答“行为是否退化”。 + +### Phase 4:有限自动触发和维护闭环 + +- 先自动化 read-only CI failure triage; +- 再允许 Agent 开修复 PR,不直推 main; +- 建立 CI/release 确定性控制带; +- 演练 rollback 后才把它列为 3σ 预批准 runbook; +- 每次事故生成 `incident.md + intent.md + eval`。 + +退出条件:无人启动时系统可以发现、诊断并准备修复,但所有风险和发布关口仍由指定人批准。 + +## 度量与验收 + +| 目标 | 初始指标 | 数据源 | +| --- | --- | --- | +| Intent 不丢失 | 接受率;首次描述到 accepted intent 的时间;build 后修改 intent 的次数 | Git history / work package | +| Design 提前暴露风险 | plan 后修改 spec 的次数;open concern 在 build 前关闭率 | Artifact commits | +| Agent 自验证有效 | first-pass CI success;人工 review 前已有 verification 的 PR 占比 | CI / PR | +| Review 不被产出淹没 | time to first review;Important finding precision;rework cycles | PR history | +| Harness 不退化 | Agent Eval pass rate、方差、flaky rate、单位任务成本 | eval workflow | +| 产品质量不退化 | CER/WER、实体保真、静音幻觉、p50/p95 latency | voice corpus evaluator | +| 发布可信 | ad-hoc public release 数量必须为 0;RC 到授权时间;rollback rehearsal 新鲜度 | release workflow / verification | +| 事故真正闭环 | 事故到 intent 时间;事故到 permanent eval 时间;同类重复事故 | incident/work package/eval | + +**[推断]** 第一阶段的硬验收应设为:所有 PR 跑 Swift tests;所有 Low/Medium/High 改动有风险分级的完整 work package;所有 UI 改动有真实 App 证据;所有公开 Release 都有非 ad-hoc 签名、成功公证与明确人工授权;所有事故都进入 Eval backlog。速度指标只能在这些质量门槛成立后优化。 + +## 一手资料索引 + +- [The AI-Native SDLC playbook](https://claude.com/blog/the-ai-native-sdlc-playbook) +- [Claude Code: Project memory / CLAUDE.md / AGENTS.md](https://code.claude.com/docs/en/memory) +- [Claude Code: Skills](https://code.claude.com/docs/en/skills) +- [Claude Code: Hooks guide](https://code.claude.com/docs/en/hooks-guide) +- [Claude Code: Hooks reference](https://code.claude.com/docs/en/hooks) +- [Claude Code: Permissions](https://code.claude.com/docs/en/permissions) +- [Claude Code: Sandboxing](https://code.claude.com/docs/en/sandboxing) +- [Claude Code: Subagents](https://code.claude.com/docs/en/sub-agents) +- [Claude Code: GitHub Actions](https://code.claude.com/docs/en/github-actions) +- [Claude Code: Code Review](https://code.claude.com/docs/en/code-review) +- [Claude Code: Monitoring / OpenTelemetry](https://code.claude.com/docs/en/monitoring-usage) +- [Claude Agent SDK overview](https://platform.claude.com/docs/en/agent-sdk/overview) + +[baseline-agents]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/AGENTS.md +[baseline-basic-checks]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/scripts/ci-basic-checks.sh +[baseline-build-app]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/scripts/build-app.sh +[baseline-build-run]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/scripts/build-and-run.sh +[baseline-claude-settings]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/.claude/settings.local.json +[baseline-entitlements]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/Resources/OpenType.entitlements +[baseline-plans]: https://github.com/IchenDEV/utter/tree/c5ee6a6525aae820329e034e64cbe835f1232712/docs/superpowers/plans +[baseline-pr-workflow]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/.github/workflows/pr.yml +[baseline-release-workflow]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/.github/workflows/release.yml +[baseline-specs]: https://github.com/IchenDEV/utter/tree/c5ee6a6525aae820329e034e64cbe835f1232712/docs/superpowers/specs +[baseline-unit-coverage]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/scripts/unit-test-coverage.sh +[baseline-voice-corpus]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/docs/superpowers/specs/voice-quality-corpus.example.jsonl +[baseline-voice-eval]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/scripts/evaluate-voice-quality.py +[baseline-voice-research]: https://github.com/IchenDEV/utter/blob/c5ee6a6525aae820329e034e64cbe835f1232712/docs/superpowers/specs/2026-07-30-voice-quality-research.md diff --git a/docs/sdlc/README.md b/docs/sdlc/README.md new file mode 100644 index 0000000..9f02471 --- /dev/null +++ b/docs/sdlc/README.md @@ -0,0 +1,135 @@ +# Utter SDLC operating model + +Utter uses a risk-scaled, artifact-driven development loop. The artifacts keep +product intent, implementation context, and verification evidence attached to +the change instead of leaving them in a chat or issue timeline. + +```text +request / bug / incident + -> intent + -> design (medium and high risk) + -> plan + -> implementation + tests + -> verification + -> pull request + human gate + -> verified signed release (notarized for Developer ID) + -> observation / incident + -> new intent +``` + +## Change lanes + +| Lane | Typical scope | Required repository artifacts | Human gate | +|---|---|---|---| +| Trivial | Prose-only docs, comments, typo-only copy | PR description | PR review when repository rules require it | +| Low | Isolated implementation or test change with no privacy, security, data, release, or UI-behavior impact | `intent.md`, `plan.md`, `verification.md` | PR approval | +| Medium | User-visible behavior, UI, model/runtime behavior, dependencies, automation | Low-risk artifacts plus `spec.md` | Intent/design review and PR approval | +| High | Permissions, privacy, security boundary, signing, release, destructive migration, public API compatibility | Full bundle, independent verification, explicit rollback | Intent/design approval, independent verifier, protected production approval | + +When uncertain, choose the higher lane. A large diff is not automatically high +risk, and a one-line permission or release change can be high risk. + +## Change bundle + +Non-trivial work lives at `docs/sdlc/changes//`: + +```text +state.json Machine-readable identity, risk, status, governed paths, and artifact paths +intent.md Problem, outcome, scope, constraints, and acceptance criteria +spec.md Design and failure analysis; required for medium/high risk +plan.md Executable work items and verification plan +verification.md Commands, results, visual/runtime evidence, and residual risk +``` + +Copy starting points from `docs/sdlc/templates/`. Status transitions are: +`intent -> designed -> planned -> implementing -> verified -> released -> closed`. +Only change the status when the corresponding artifact exists and its evidence +is current. An agent may record evidence, but may not represent its own output as +human approval. + +`python3 scripts/sdlc.py validate --worktree` validates local work. Pull-request +CI compares the branch to its base and requires a changed verified bundle when +governed paths change. Trivial documentation-only changes stay on the fast path. + +The validator also enforces a minimum risk for deterministic control surfaces: +entitlements, workflows, signing/build/release verification, the SDLC validator, +and its CI guard are high risk; dependencies, agent context, issue/review policy, +and other automation are at least medium risk. + +## Definition of ready + +Implementation can start when: + +- the problem and desired outcome are observable; +- affected users/systems, scope, constraints, and open questions are recorded; +- acceptance criteria are testable; +- medium/high-risk design choices and rollback are reviewed; +- the plan names both automated and manual verification. + +## Definition of done + +A change is ready for PR approval when: + +- each acceptance criterion has evidence in `verification.md`; +- `bash scripts/ci-basic-checks.sh` and `swift test` pass; +- a release-style app build passes for packaging, dependency, or runtime changes; +- user-visible macOS UI changes have real-window light/dark and relevant narrow- + width evidence, not only unit or component tests; +- privacy, permission, external-service, and failure paths were exercised or are + explicitly listed as residual risk; +- reviewer findings are resolved and rollback remains possible. + +## Deterministic controls + +Repository instructions and this document are guidance. Enforcement lives in: + +- `scripts/sdlc.py`: artifact schema and governed-change check; +- `scripts/ci-basic-checks.sh`: linked resources, localization, identifiers, and + repository invariants; +- `.github/workflows/pr.yml`: artifact validation, unit tests, and release-style + app build, summarized by the stable `SDLC Gate` job; +- `.github/workflows/release.yml`: main-ancestry, tests, explicit signing-mode + detection, artifact verification, checksum, and Developer ID notarization. + +Local success is evidence for a commit, not proof that a GitHub check, Apple +notarization, or clean-machine path passed. A verified self-signed release is +still not Apple-trusted or notarized and must say so in its release notes. + +## Human control points + +Humans own outcome, risk acceptance, and production authorization. Agents may +draft artifacts, implement, test, review, and diagnose, but must stop for: + +- unresolved product intent or a material scope choice; +- a security/privacy tradeoff not already approved; +- credentials or a protected production action; +- acceptance of known residual risk. + +The repository-side controls need matching GitHub settings. See +[`github-controls.md`](github-controls.md); those settings must be verified in +GitHub and are not made effective by committing YAML alone. + +Use [`review-policy.md`](review-policy.md) for the independent verification pass +and [`release-runbook.md`](release-runbook.md) for signed releases. + +## Feedback and maintenance + +Use the incident issue form or `templates/incident.md`. Detection should remain +deterministic wherever possible. An incident can trigger agent diagnosis, but +write actions stay limited to approved tools and paths. + +Every production incident must produce at least one of: + +- a regression test; +- a deterministic guardrail; +- an explicit eval case once a model-based eval runner exists; +- a documented reason the failure cannot be reproduced automatically. + +The corrective work starts as a new intent and links back to the incident. Do +not close the incident until the new control has verification evidence. + +## Research basis + +See [`../research/ai-native-sdlc-utter-2026-08-25.md`](../research/ai-native-sdlc-utter-2026-08-25.md) +for the primary-source audit, verified Anthropic claims, Utter baseline, and the +boundary between source facts and project-specific decisions. diff --git a/docs/sdlc/changes/2026-08-25-ai-native-sdlc/intent.md b/docs/sdlc/changes/2026-08-25-ai-native-sdlc/intent.md new file mode 100644 index 0000000..21588b8 --- /dev/null +++ b/docs/sdlc/changes/2026-08-25-ai-native-sdlc/intent.md @@ -0,0 +1,64 @@ +# Intent: Adopt an artifact-driven, fail-closed SDLC + +## Problem + +Utter's implementation speed is not matched by durable intent, verification, +and release controls. Product context is spread across chats, issues, old specs, +and code. Pull-request CI builds the app but does not run the unit-test suite. +The tag workflow supports the configured `OpenType Signing` self-signed identity +but does not distinguish its trust level from Apple Developer ID, and it can +fall back to ad-hoc signing when certificate import fails. GitHub's `main` +ruleset is disabled, so review and successful checks are not enforced before +merge. + +## Outcome + +Every material change advances through a compact chain of human- and +machine-readable artifacts. Agents receive explicit intent and acceptance +criteria, CI supplies deterministic feedback, reviewers see current evidence, +and the release path fails closed unless the exact artifact uses the configured +identity, is verified, labeled with its signing mode, and approved. Developer ID +releases additionally require Apple notarization; the existing self-signed +release path remains operational without being represented as Apple-trusted. + +## Scope + +In scope: repository instructions, change/incident templates, artifact schema, +local validation, PR checks, release gates, and documentation of GitHub-side +controls. A low-friction path remains for prose-only changes. + +Out of scope: enabling GitHub rulesets or environment approvals through the API, +provisioning Apple credentials, adding product telemetry, or claiming App Store +readiness. Those actions need administrator or credential-holder control. + +## Constraints + +- Preserve the Swift Package architecture and existing source behavior. +- Use only tools already present on GitHub macOS runners and developer Macs. +- Keep agents from self-approving product or production decisions. +- Treat local builds, GitHub checks, Developer ID distribution, notarization, + and App Store submission as distinct evidence. +- Do not force full design paperwork onto prose-only changes. + +## Acceptance criteria + +- Governed diffs without a changed verified SDLC bundle fail validation. +- Medium/high-risk verified bundles require intent, spec, plan, and verification. +- PR CI runs the validator tests, repository checks, `swift test`, and an + xcodebuild-backed app bundle build, with a stable aggregate gate. +- Release CI accepts only a SemVer tag whose commit is on `origin/main`, requires + the configured signing identity, verification, and a SHA-256 checksum, and + preserves the existing self-signed release path with an explicit warning. +- A detected Developer ID identity additionally requires notarization + credentials, Apple acceptance, stapling, and Gatekeeper verification. +- Incident intake requires impact, detection, containment, follow-up intent, and + a regression control. +- External GitHub controls and their current unverified state remain explicit. + +## Open questions + +The maintainer still needs to decide who can approve the `production` +environment, whether emergency bypass is allowed, and when to migrate the +public default from project self-signing to Developer ID. Model-based continuous +evals also need a future runner and a corpus of real accepted/failed tasks; +deterministic harness regression tests are the initial control. diff --git a/docs/sdlc/changes/2026-08-25-ai-native-sdlc/plan.md b/docs/sdlc/changes/2026-08-25-ai-native-sdlc/plan.md new file mode 100644 index 0000000..66d2baf --- /dev/null +++ b/docs/sdlc/changes/2026-08-25-ai-native-sdlc/plan.md @@ -0,0 +1,39 @@ +# Plan: Adopt an artifact-driven, fail-closed SDLC + +## Work items + +- [x] Research the first-party AI-native SDLC guidance and separate source facts + from project-specific decisions. +- [x] Audit current repository artifacts, CI, releases, and live GitHub controls. +- [x] Define risk lanes, lifecycle artifacts, definitions of ready/done, human + gates, and incident feedback. +- [x] Implement and unit-test deterministic artifact validation. +- [x] Add intent/incident intake and PR evidence templates. +- [x] Add Swift tests and a stable aggregate PR gate. +- [x] Make releases fail closed on ancestry, configured signing identity, + artifact verification, approval, and checksums while keeping the existing + self-signed path and requiring notarization for Developer ID. +- [x] Update agent context and contributor-facing entry points. +- [x] Run all applicable local checks and record exact evidence. + +## Verification plan + +- [x] `python3 scripts/tests/test_sdlc.py` +- [x] `python3 scripts/sdlc.py validate --worktree` +- [x] `bash scripts/ci-basic-checks.sh` +- [x] `swift test` +- [x] `bash -n` on changed shell scripts +- [x] parse changed YAML files +- [x] `./scripts/build-app.sh --app-only --sign=-` +- [x] run the release verifier on the local app without claiming Developer ID or + notarization success +- [x] exercise the current GitHub self-signed release artifact against the + non-notarized verification branch +- [x] `git diff --check` + +## Human gates + +The maintainer accepts the intent/design through review of this change. A GitHub +administrator must separately activate the `main` ruleset. A credential holder +must configure and approve the `production` environment and Apple secrets. No +agent may mark those external gates complete from repository evidence alone. diff --git a/docs/sdlc/changes/2026-08-25-ai-native-sdlc/spec.md b/docs/sdlc/changes/2026-08-25-ai-native-sdlc/spec.md new file mode 100644 index 0000000..692bfb7 --- /dev/null +++ b/docs/sdlc/changes/2026-08-25-ai-native-sdlc/spec.md @@ -0,0 +1,73 @@ +# Spec: Adopt an artifact-driven, fail-closed SDLC + +## Context + +The repository already has architecture guidance in `AGENTS.md`, historical +specs/plans, a lightweight linked-file check, a release-style app builder, and +unit tests. It lacks a single lifecycle contract tying those pieces together. +The current GitHub repository has an inactive `main` ruleset and no branch +protection. Only certificate secrets are configured; Apple notarization secrets +are absent. + +## Design + +Add a vendor-neutral lifecycle under `docs/sdlc/`. A change bundle contains a +small JSON state record plus Markdown intent, optional/required design, plan, +and verification artifacts. `scripts/sdlc.py` validates status values, +required headings, placeholder removal, path containment, and whether governed +diffs are covered by a changed verified bundle. + +Risk controls the artifact weight. Prose-only work uses the PR fast path. Low +risk skips a design artifact. Medium/high risk requires it. Verified work must +have current evidence; release and closure remain later states. + +GitHub issue forms capture raw intent and incidents. The PR template links the +bundle and exposes risk, tests, visual/runtime evidence, and residual risk. PR CI +uses a stable aggregate job so a future ruleset needs only one required check. + +Release CI reruns repository checks and tests, verifies the tag and main +ancestry, imports the configured signing identity, builds the DMG, and proves +the app authority matches that identity. It then classifies the actual app +signature. Developer ID proceeds through Apple notarization, stapling, and +Gatekeeper assessment. The existing project self-signed identity skips Apple- +only checks but retains mounted-DMG, signature, binary-equality, checksum, draft +round-trip, and immutable publication checks, with an explicit release warning. + +## Safety and failure modes + +- Markdown cannot prove human approval. Approval is enforced by an active + GitHub ruleset and protected environment, both documented as external gates. +- An agent could write weak evidence. Required CI and independent high-risk + review reduce but do not eliminate this risk. +- Missing or unusable signing credentials block every release instead of + falling back to an ad-hoc artifact. Apple credentials are required only when + the built app is signed with Developer ID. +- A self-signed artifact is not Apple-trusted; the workflow and release notes + preserve that distinction even though the existing distribution path remains + supported. +- Existing docs and research do not retroactively require bundles; governed + implementation, test, automation, and product-site paths do. +- The release verifier proves bundle/signature/notarization invariants, not + clean-machine behavior or App Store acceptance. + +## Test strategy + +- Unit-test bundle validation, missing artifacts, fast-path detection, and the + governed-path gate with Python's standard library. +- Run the validator against the actual worktree. +- Run existing linked checks and the complete Swift test suite. +- Parse workflow YAML and inspect the final diff. +- Build and verify an ad-hoc app locally as packaging evidence. +- Verify the current GitHub self-signed release artifact against the strict + non-notarized branch and statically exercise both workflow branches. +- Developer ID and notarization checks require the protected GitHub environment. + +## Rollout and rollback + +Merge repository controls first, then enable the `main` ruleset and configure +the `production` environment. Existing repository-level self-signing secrets +remain compatible; add the three Apple notarization secrets when migrating to +Developer ID. Roll back by reverting the workflow/control commit; do not weaken +a failing release job inline or publish its unverified DMG manually. If the +artifact gate proves too heavy, narrow governed paths through a reviewed low- +risk change instead of bypassing validation. diff --git a/docs/sdlc/changes/2026-08-25-ai-native-sdlc/state.json b/docs/sdlc/changes/2026-08-25-ai-native-sdlc/state.json new file mode 100644 index 0000000..cb07ffc --- /dev/null +++ b/docs/sdlc/changes/2026-08-25-ai-native-sdlc/state.json @@ -0,0 +1,31 @@ +{ + "schemaVersion": 1, + "id": "2026-08-25-ai-native-sdlc", + "title": "Adopt an artifact-driven SDLC with verified signed releases", + "risk": "high", + "status": "verified", + "owners": [ + "repository maintainer" + ], + "acceptanceCriteria": [ + "Non-trivial changes carry machine-validated intent, design, plan, and verification artifacts scaled by risk.", + "Pull requests run artifact validation, unit tests, and a release-style app build behind one stable required-check name.", + "Releases cannot proceed from a non-main commit or without the configured signing identity, artifact verification, and a checksum; Developer ID releases additionally require Apple notarization while the existing self-signed path remains supported and clearly labeled.", + "Production incidents feed a regression control and a new corrective intent back into the lifecycle.", + "The repository documents which human approval controls still require GitHub configuration." + ], + "governedPaths": [ + ".github/", + ".gitignore", + "AGENTS.md", + "CLAUDE.md", + "docs/sdlc/", + "scripts/" + ], + "artifacts": { + "intent": "intent.md", + "spec": "spec.md", + "plan": "plan.md", + "verification": "verification.md" + } +} diff --git a/docs/sdlc/changes/2026-08-25-ai-native-sdlc/verification.md b/docs/sdlc/changes/2026-08-25-ai-native-sdlc/verification.md new file mode 100644 index 0000000..45f1164 --- /dev/null +++ b/docs/sdlc/changes/2026-08-25-ai-native-sdlc/verification.md @@ -0,0 +1,76 @@ +# Verification: Adopt an artifact-driven, fail-closed SDLC + +## Evidence + +Environment: baseline commit `c5ee6a6525aae820329e034e64cbe835f1232712`, +branch `codex/ai-native-sdlc`, macOS 27.0 build 26A5416b, Xcode 27.0 beta +build 27A5237l, Swift 6.4, arm64. + +| Check | Result | Evidence | +|---|---|---| +| Primary-source research | Pass | Anthropic title/date/author, six stages, Artifact triggers, eval guidance, control bands, and human production boundary verified in `docs/research/ai-native-sdlc-utter-2026-08-25.md` | +| `python3 scripts/tests/test_sdlc.py` | Pass | 13 tests; includes missing/distinct/alias/symlink artifacts, delete/rename/divergent diffs, governed coverage, and minimum risks | +| Build/release version regressions | Pass | Tagless checkout falls back to `0.0.0`; stable tags resolve; leading-zero and prerelease/public build tags are rejected | +| Dual signing workflow regressions | Pass | Release workflow YAML and every `run` block parsed; tests require both self-signed verifier calls, final Developer ID notarization verification, certificate fingerprint binding, immutable assets, and no ad-hoc fallback | +| Current GitHub self-signed release | Pass | v0.0.43 DMG (`sha256:2737cd9bb55224d03130822ba316fd6ea12db3793bfbc766184aa12048de8e74`) passed strict signature, hardened runtime, certificate SHA-256, mounted-DMG, full bundle manifest/content/permission, and binary checks; `Authority=OpenType Signing`, `TeamIdentifier=not set`, certificate `8DEC72880E13997A022BB5C446E8B54378FE2D17A7211FDAB6C01BBA860C4B9D` | +| Self-signed negative control | Pass | The local ad-hoc app was rejected by `--require-self-signed` because it has no signing authority | +| `bash scripts/ci-basic-checks.sh` | Pass | SDLC tests, release workflow guards, plist/localization, identifiers, industry lexicon, resources, conflict, credential-file, and symlink checks passed | +| `DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer swift test` | Pass | 564 XCTest tests passed, 8 environment-gated tests skipped; 1 Swift Testing test also passed | +| `./scripts/build-app.sh --app-only --sign=- --version=0.0.43` | Pass as local evidence | Rebuilt with xcodebuild/Metal; arm64 app/helper, bundle resources, hardened-runtime ad-hoc signature, and artifact verification passed; not distribution evidence | +| Local DMG digest | Pass | `54f07dab0e28c0172ba19cc53403862ac013adb37a991de7b8dc30a99b130077`; local ad-hoc artifact only, not a release digest | +| Shell/YAML/diff validation | Pass | Changed shell scripts passed `bash -n`; workflows and issue forms parsed as YAML; `git diff --check` passed | +| Independent fresh-context verification | Pass | Dual-signing review found incomplete DMG comparison, missing certificate fingerprint binding, weak workflow assertions, stale evidence, and an unprotected-environment gap; all fixes were applied and final re-review reported no actionable findings | +| Live GitHub controls | Partial | Repository secrets contain the current P12 and password, so the self-signed path remains usable. `main` ruleset is disabled, branch is unprotected, `production` has no verified required reviewer, and Developer ID/notarization credentials are absent | + +The first bare `swift test` attempt failed before compilation because the machine +selected Command Line Tools and could not find `metal`. Re-running with the full +Xcode beta developer directory succeeded. PR/release workflows explicitly +install the Metal toolchain before tests/builds. + +## Acceptance criteria + +- Non-trivial governed diffs require a changed verified bundle that covers every + governed path and meets its deterministic minimum risk — pass. +- Medium/high-risk verified bundles require distinct, safe, non-symlink intent, + spec, plan, and verification files — pass. +- PR CI defines artifact checks, Swift tests, an xcodebuild-backed app build, and + one stable `SDLC Gate`; direct pushes compare against the previous SHA and fail + closed when it is unavailable — pass by code/tests, pending first GitHub run. +- Release CI restricts stable numeric tags to commits on `main`, requires the + configured certificate and exact app certificate fingerprint, verifies both + the built and mounted apps plus the complete bundle, round-trips draft assets + and checksum, then publishes immutably. The existing self-signed identity is + explicitly labeled; Developer ID additionally requires notarization, stapling, + and Gatekeeper assessment — pass by code and current self-signed artifact, + pending a live run of the new workflow and a future Developer ID run. +- Incident intake requires impact, deterministic detection, containment, + corrective intent, and a regression control — pass. +- GitHub-side human controls are documented without claiming they are active — + pass; external setup remains open. + +## Residual risk + +- The repository cannot enforce review or block a direct/force push until an + administrator activates the documented `main` ruleset. +- The repository's current P12/password are sufficient for the explicit self- + signed path. Because `production` does not yet have a verified required + reviewer, GitHub may create/use it without protected approval; this preserves + the existing chain but remains an external governance gap. +- Developer ID and its notarization secrets are not configured. That optional + branch will fail closed if a Developer ID certificate is used without them. +- The self-signed release is not Apple-trusted or notarized and may require + manual macOS approval; the workflow must preserve the release warning. +- Developer ID, Apple notarization, Gatekeeper `spctl`, GitHub draft asset + round-trip, and final publication require a live protected run. +- Eight environment-gated Swift integration tests were skipped; their existing + opt-in requirements are unchanged by this SDLC-only change. +- Model-based Agent Evals need 20–50 accepted, sanitized real tasks and a stable + runner; current CI covers deterministic harness regressions only. + +## Decision + +Ready for human review as a verified high-risk repository-control change. The +current self-signed artifact path is validated and final independent re-review +reported no actionable findings. Protected production approval, GitHub control +activation, Developer ID credentials, and a live run of the updated workflow +remain separate human/external gates. diff --git a/docs/sdlc/github-controls.md b/docs/sdlc/github-controls.md new file mode 100644 index 0000000..3686a17 --- /dev/null +++ b/docs/sdlc/github-controls.md @@ -0,0 +1,58 @@ +# GitHub controls required for the SDLC + +These controls live outside the Git tree. Their absence is a release/process +gap even when repository CI is green. + +## `main` ruleset + +Enable an active ruleset targeting `main` with: + +- pull requests required; +- at least one approval; +- code-owner review for the sensitive paths in `.github/CODEOWNERS`; +- approval of the most recent reviewable push; +- all review conversations resolved; +- required status check `SDLC Gate`; +- force pushes and deletions blocked; +- bypass limited to an explicit emergency role, with an audit reason. + +The stable gate name lets internal CI jobs change without repeatedly editing +the ruleset. + +## `production` environment + +Create a `production` environment and configure: + +- a required human reviewer; +- deployment branches/tags restricted to tags matching `v*`; +- no administrator bypass for routine releases; +- `APPLE_CERTIFICATE_P12` and `APPLE_CERTIFICATE_PASSWORD` as repository or + environment secrets; +- `APPLE_ID`, `APPLE_TEAM_ID`, and `APPLE_APP_PASSWORD` when the certificate is + a Developer ID identity. + +The release workflow fails when the configured signing identity cannot be +imported or does not match the built app. Developer ID releases additionally +fail when notarization credentials or Apple acceptance are missing. The +existing project self-signed identity remains supported without being described +as Developer ID or notarized. + +## Release operation + +1. Merge a verified change through protected `main`. +2. Confirm the `SDLC Gate` result on the exact release commit. +3. Create an annotated `vMAJOR.MINOR.PATCH` tag on that commit. +4. Approve the protected `production` deployment. +5. Require mounted-DMG verification and the published SHA-256 checksum. For a + Developer ID identity, also require successful Apple notarization and + stapling. + +An ad-hoc artifact is never a release. A project self-signed release is allowed +only through the explicit self-signed branch and must be labeled as not +Apple-notarized; it does not provide Developer ID trust or Gatekeeper acceptance. + +## Periodic audit + +Quarterly, and after any administrator change, verify the active ruleset, +environment reviewers, secret names, action permissions, and one dry-run release +candidate. Record the audit as a low-risk change bundle. diff --git a/docs/sdlc/release-runbook.md b/docs/sdlc/release-runbook.md new file mode 100644 index 0000000..966ad81 --- /dev/null +++ b/docs/sdlc/release-runbook.md @@ -0,0 +1,59 @@ +# Signed release runbook + +Release is a high-risk operation. The tag workflow accepts the configured +project self-signed identity and Apple Developer ID, but never falls back to +ad-hoc signing when certificate import or artifact verification fails. + +## Prepare + +1. Confirm the candidate commit is on protected `main` and its `SDLC Gate` is + green. +2. Review the change bundle's acceptance evidence, skipped checks, residual risk, + and rollback notes. +3. Confirm the `production` environment, required reviewer, tag restriction, + signing certificate, and applicable secret names against + `github-controls.md`. +4. Create a `vMAJOR.MINOR.PATCH` tag on the exact candidate commit. + +## Authorize and verify + +Every protected workflow run must: + +- rerun repository checks and Swift tests; +- import the configured code-signing identity and prove the built app uses it; +- build the app and DMG with the tagged version; +- verify the app signature, architecture, identifiers, version, helper, mounted + DMG contents, and binary equality; +- publish the DMG and its SHA-256 checksum only after artifact checks; require + human environment approval once the external `production` protection is active. + +For Developer ID, the run must additionally verify the Apple team identifier and +hardened runtime, receive an accepted notarization result, staple the ticket, +and pass Gatekeeper assessment. For the project self-signed identity, the +release notes must warn that the build is not Apple-notarized and may require +manual approval on macOS. + +Keep the workflow URL, release URL, commit SHA, signing mode, checksum, and any +notarization result as release evidence. + +## Stop conditions + +Stop without publishing if the commit is not on `main`, a required check is not +green, the version's release already exists, the configured identity is missing +or does not match the app, Developer ID notarization fails, the mounted artifact +differs, or a reviewer does not accept residual risk. Diagnose through a normal +change bundle and PR; do not edit the tag, replace published same-version assets, +or silently fall back to ad-hoc signing. + +If an upload or download verification fails, the workflow leaves a draft. Record +the failure and inspect its assets before deleting that draft and rerunning; a +published same-version release is never replaced. + +## Rollback + +1. Mark the affected release as non-latest or remove it from the public download + path without deleting evidence. +2. Restore the previous known-good release as the recommended download. +3. Publish user guidance for settings, history, and model-cache compatibility. +4. Open an incident, link a corrective intent, and add a regression control. +5. Rehearse this path at least quarterly and after release-system changes. diff --git a/docs/sdlc/review-policy.md b/docs/sdlc/review-policy.md new file mode 100644 index 0000000..b52162b --- /dev/null +++ b/docs/sdlc/review-policy.md @@ -0,0 +1,37 @@ +# Review policy + +Review the accepted intent and committed evidence before the diff. A passing CI +run proves only its named checks; it does not prove the product outcome. + +## Review passes + +1. **Intent and scope:** Does the diff satisfy each acceptance criterion without + expanding scope or silently changing the product decision? +2. **Correctness:** Check state, concurrency, failure recovery, boundary cases, + compatibility, and adjacent regressions. +3. **Privacy and security:** Check microphone, screen, selected text, history, + remote requests, logs, credentials, authorization, and least privilege. +4. **macOS and release:** Check native interaction, localization, TCC behavior, + signing, Metal resources, bundle contents, upgrade safety, and rollback. + +## Finding contract + +An actionable finding includes: + +- a tight file/line location; +- the triggering condition; +- concrete user/system impact; +- a severity based on impact and likelihood; +- a way to reproduce or verify the correction. + +Do not bury correctness or risk findings under style comments. If no actionable +finding remains, say so and list any evidence gap separately. + +## Independent verification + +Medium-risk work benefits from a fresh-context read-only review. High-risk work +requires one. The verifier reads the accepted artifacts, inspects the actual +diff and results, and reruns risk-critical checks. The verifier reports findings +and evidence gaps; it does not repair the change or approve production. + +The author or implementation agent cannot satisfy the human approval gate. diff --git a/docs/sdlc/templates/incident.md b/docs/sdlc/templates/incident.md new file mode 100644 index 0000000..69ae4e1 --- /dev/null +++ b/docs/sdlc/templates/incident.md @@ -0,0 +1,25 @@ +# Incident: {{INCIDENT_TITLE}} + +## Impact + +{{Affected users, systems, duration, and severity.}} + +## Detection and timeline + +{{Deterministic signal, key events, and when containment started.}} + +## Containment + +{{Actions taken, approval boundary, and current state.}} + +## Cause and contributing conditions + +{{Evidence-backed cause; label unknowns explicitly.}} + +## Corrective intent + +{{Link the follow-up change bundle and its owner.}} + +## Regression control + +{{Test, guardrail, eval case, or explicit reason automation is not possible.}} diff --git a/docs/sdlc/templates/intent.md b/docs/sdlc/templates/intent.md new file mode 100644 index 0000000..2269e94 --- /dev/null +++ b/docs/sdlc/templates/intent.md @@ -0,0 +1,25 @@ +# Intent: {{CHANGE_TITLE}} + +## Problem + +{{Describe the observed problem without prescribing implementation.}} + +## Outcome + +{{Describe the user or system outcome that should be observable.}} + +## Scope + +{{List affected users, systems, and explicit non-goals.}} + +## Constraints + +{{Record privacy, platform, compatibility, cost, schedule, and policy limits.}} + +## Acceptance criteria + +- {{Write a testable criterion.}} + +## Open questions + +{{Write unresolved decisions, or `None`.}} diff --git a/docs/sdlc/templates/plan.md b/docs/sdlc/templates/plan.md new file mode 100644 index 0000000..9173f2a --- /dev/null +++ b/docs/sdlc/templates/plan.md @@ -0,0 +1,15 @@ +# Plan: {{CHANGE_TITLE}} + +## Work items + +- [ ] {{Small, verifiable implementation step.}} + +## Verification plan + +- [ ] `bash scripts/ci-basic-checks.sh` +- [ ] `swift test` +- [ ] {{Risk-specific runtime or visual check.}} + +## Human gates + +{{Name the decisions and production actions that need human approval.}} diff --git a/docs/sdlc/templates/spec.md b/docs/sdlc/templates/spec.md new file mode 100644 index 0000000..821002d --- /dev/null +++ b/docs/sdlc/templates/spec.md @@ -0,0 +1,21 @@ +# Spec: {{CHANGE_TITLE}} + +## Context + +{{Summarize the relevant existing architecture and evidence.}} + +## Design + +{{Describe interfaces, state, data flow, ownership, and non-goals.}} + +## Safety and failure modes + +{{Describe privacy/security boundaries, failures, and containment.}} + +## Test strategy + +{{Map acceptance criteria to automated, integration, and manual checks.}} + +## Rollout and rollback + +{{Describe release order, observation, stop conditions, and recovery.}} diff --git a/docs/sdlc/templates/verification.md b/docs/sdlc/templates/verification.md new file mode 100644 index 0000000..fb6e226 --- /dev/null +++ b/docs/sdlc/templates/verification.md @@ -0,0 +1,20 @@ +# Verification: {{CHANGE_TITLE}} + +## Evidence + +| Check | Result | Evidence | +|---|---|---| +| `bash scripts/ci-basic-checks.sh` | {{Pass/fail/not run}} | {{Output or link}} | +| `swift test` | {{Pass/fail/not run}} | {{Output or link}} | + +## Acceptance criteria + +- {{Criterion}} — {{pass/fail and evidence}} + +## Residual risk + +{{List unresolved risk and owner, or `None`.}} + +## Decision + +{{Ready for review, blocked, or rejected. Human approval is recorded separately.}} diff --git a/scripts/build-app.sh b/scripts/build-app.sh index c9d89e1..1548274 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -24,14 +24,15 @@ BUILD_PRODUCT="OpenType" SCHEME_NAME="OpenType" BUNDLE_ID="com.opentype.voiceinput" CLI_HELPER_NAME="opentype-cli" -# Default: use latest git tag (strip leading "v"), fallback to 0.0.0-dev -if [ -z "${VERSION:-}" ]; then - VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "0.0.0-dev")" -fi - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +# Shallow/tagless development checkouts use 0.0.0. Public release tags are +# validated separately by release-version.sh and passed explicitly. +if [ -z "${VERSION:-}" ]; then + VERSION="$("${SCRIPT_DIR}/build-version.sh" "${PROJECT_DIR}")" +fi + DERIVED_DATA="${PROJECT_DIR}/.build/xcode" BUILD_DIR="${DERIVED_DATA}/Build/Products/Release" DIST_DIR="${PROJECT_DIR}/dist" @@ -186,6 +187,9 @@ fi # ─── Step 5: Create DMG ──────────────────────────────────────────────────────── if [ "$APP_ONLY" = true ]; then + "${SCRIPT_DIR}/verify-release-artifact.sh" \ + --app "${APP_BUNDLE}" \ + --version "${VERSION}" echo "" echo "═══════════════════════════════════════════════════" echo " Done! ${APP_BUNDLE}" @@ -215,6 +219,11 @@ rm -rf "${DMG_TMP}" done_msg "DMG created" +"${SCRIPT_DIR}/verify-release-artifact.sh" \ + --app "${APP_BUNDLE}" \ + --dmg "${DMG_PATH}" \ + --version "${VERSION}" + # ─── Summary ──────────────────────────────────────────────────────────────────── echo "" diff --git a/scripts/build-version.sh b/scripts/build-version.sh new file mode 100755 index 0000000..4c044c0 --- /dev/null +++ b/scripts/build-version.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Resolve a numeric local bundle version without weakening public tag validation. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPOSITORY="${1:-.}" +TAG="$(git -C "$REPOSITORY" describe --tags --abbrev=0 2>/dev/null || true)" + +if [ -n "$TAG" ] && VERSION="$("$SCRIPT_DIR/release-version.sh" "$TAG" 2>/dev/null)"; then + printf '%s\n' "$VERSION" +else + printf '%s\n' "0.0.0" +fi diff --git a/scripts/ci-basic-checks.sh b/scripts/ci-basic-checks.sh index 5bc8a74..d8dc0ab 100755 --- a/scripts/ci-basic-checks.sh +++ b/scripts/ci-basic-checks.sh @@ -20,6 +20,12 @@ step() { step "Checking Package.swift" swift package describe >/dev/null +step "Checking SDLC artifacts and harness regression tests" +python3 scripts/sdlc.py validate +python3 scripts/tests/test_sdlc.py +bash scripts/tests/test_build_version.sh +bash scripts/tests/test_release_version.sh + step "Linting property lists and localized strings" plutil -lint Resources/Info.plist plutil -lint Resources/OpenType.entitlements @@ -102,6 +108,21 @@ if [ -n "$conflict_markers" ]; then fail "found unresolved conflict markers" fi +step "Checking tracked secret-bearing file types" +sensitive_tracked="$(git ls-files | grep -E '(^|/)(\.env($|\.)|[^/]+\.(p12|pem|key|mobileprovision|provisionprofile))$' || true)" +if [ -n "$sensitive_tracked" ]; then + echo "$sensitive_tracked" + fail "found tracked files that may contain credentials" +fi + +private_key_marker="-----BEGIN PRIVATE"$' '"KEY-----" +private_key_hits="$(git grep -n -F -- "$private_key_marker" -- . \ + ':(exclude)scripts/ci-basic-checks.sh' || true)" +if [ -n "$private_key_hits" ]; then + echo "$private_key_hits" + fail "found a committed private key marker" +fi + step "Checking for broken symlinks" if find . \ -path ./.git -prune -o \ diff --git a/scripts/release-version.sh b/scripts/release-version.sh new file mode 100755 index 0000000..122481f --- /dev/null +++ b/scripts/release-version.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Validate a stable public release tag and print its numeric bundle version. + +set -euo pipefail + +TAG="${1:-}" +if [[ ! "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "error: release tag must be stable vMAJOR.MINOR.PATCH without leading zeroes" >&2 + exit 1 +fi + +printf '%s\n' "${TAG#v}" diff --git a/scripts/sdlc.py b/scripts/sdlc.py new file mode 100755 index 0000000..253e2f2 --- /dev/null +++ b/scripts/sdlc.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Validate Utter's artifact-driven SDLC contract without third-party packages.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +from sdlc_policy import REQUIRED_HEADINGS, RISKS, STATUSES, is_governed_path, minimum_risk + +ID_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$") +PLACEHOLDER_PATTERN = re.compile(r"\{\{[^}]+\}\}") + + +def required_artifacts(status: str, risk: str) -> set[str]: + status_index = STATUSES.index(status) + required = {"intent"} + if risk in {"medium", "high"} and status_index >= STATUSES.index("designed"): + required.add("spec") + if status_index >= STATUSES.index("planned"): + required.add("plan") + if status_index >= STATUSES.index("verified"): + required.add("verification") + return required + + +def path_is_in_scope(path: str, scope: str) -> bool: + return path.startswith(scope) if scope.endswith("/") else path == scope + + +def load_state(state_path: Path, errors: list[str]) -> dict | None: + try: + value = json.loads(state_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + errors.append(f"{state_path}: invalid JSON: {error}") + return None + if not isinstance(value, dict): + errors.append(f"{state_path}: top-level value must be an object") + return None + return value + + +def validate_artifact( + bundle_dir: Path, artifact: str, relative_path: object, errors: list[str] +) -> None: + if not isinstance(relative_path, str) or not relative_path: + errors.append(f"{bundle_dir}: artifact '{artifact}' needs a relative path") + return + relative = Path(relative_path) + if relative.is_absolute() or ".." in relative.parts: + errors.append(f"{bundle_dir}: artifact '{artifact}' must use a safe relative path") + return + artifact_path = bundle_dir / relative + try: + artifact_path.resolve().relative_to(bundle_dir.resolve()) + except ValueError: + errors.append(f"{bundle_dir}: artifact '{artifact}' escapes its bundle") + return + current = bundle_dir + for part in relative.parts: + current /= part + if current.is_symlink(): + errors.append(f"{artifact_path}: artifact paths may not contain symlinks") + return + if not artifact_path.is_file(): + errors.append(f"{artifact_path}: required artifact is missing") + return + text = artifact_path.read_text(encoding="utf-8") + for heading in REQUIRED_HEADINGS[artifact]: + if heading not in text: + errors.append(f"{artifact_path}: missing heading '{heading}'") + if PLACEHOLDER_PATTERN.search(text): + errors.append(f"{artifact_path}: contains an unfilled template placeholder") + + +def validate_bundle(state_path: Path) -> tuple[dict | None, list[str]]: + errors: list[str] = [] + state = load_state(state_path, errors) + if state is None: + return None, errors + + bundle_dir = state_path.parent + bundle_id = state.get("id") + if bundle_id != bundle_dir.name or not isinstance(bundle_id, str) or not ID_PATTERN.match(bundle_id): + errors.append(f"{state_path}: id must match the yyyy-mm-dd-slug directory") + if state.get("schemaVersion") != 1: + errors.append(f"{state_path}: schemaVersion must be 1") + if not isinstance(state.get("title"), str) or not state["title"].strip(): + errors.append(f"{state_path}: title must be non-empty") + + risk = state.get("risk") + status = state.get("status") + if risk not in RISKS: + errors.append(f"{state_path}: risk must be one of {', '.join(RISKS)}") + if status not in STATUSES: + errors.append(f"{state_path}: status must be one of {', '.join(STATUSES)}") + + owners = state.get("owners") + if not isinstance(owners, list) or not owners or not all( + isinstance(owner, str) and owner.strip() for owner in owners + ): + errors.append(f"{state_path}: owners must be a non-empty string array") + criteria = state.get("acceptanceCriteria") + if not isinstance(criteria, list) or not criteria or not all( + isinstance(criterion, str) and criterion.strip() for criterion in criteria + ): + errors.append(f"{state_path}: acceptanceCriteria must be a non-empty string array") + governed_paths = state.get("governedPaths") + if not isinstance(governed_paths, list) or not governed_paths or not all( + isinstance(path, str) + and path.strip() + and not path.startswith(("/", "../")) + and "/../" not in path + and path not in {".", "./"} + for path in governed_paths + ): + errors.append(f"{state_path}: governedPaths must contain safe repository-relative paths") + + artifacts = state.get("artifacts") + if not isinstance(artifacts, dict): + errors.append(f"{state_path}: artifacts must be an object") + return state, errors + if risk in RISKS and status in STATUSES: + required = required_artifacts(status, risk) + required_paths = [artifacts.get(artifact) for artifact in required] + canonical_paths = [ + (state_path.parent / path).resolve() + for path in required_paths + if isinstance(path, str) + and not Path(path).is_absolute() + and ".." not in Path(path).parts + ] + if len(canonical_paths) != len(set(canonical_paths)): + errors.append(f"{state_path}: required artifacts must use distinct files") + for artifact in required: + validate_artifact(bundle_dir, artifact, artifacts.get(artifact), errors) + return state, errors + + +def validate_repository(root: Path) -> tuple[dict[Path, dict], list[str]]: + states: dict[Path, dict] = {} + errors: list[str] = [] + changes_root = root / "docs" / "sdlc" / "changes" + if not changes_root.exists(): + return states, errors + for state_path in sorted(changes_root.glob("*/state.json")): + state, bundle_errors = validate_bundle(state_path) + errors.extend(bundle_errors) + if state is not None: + states[state_path.relative_to(root)] = state + orphan_dirs = sorted(path for path in changes_root.iterdir() if path.is_dir() and not (path / "state.json").is_file()) + errors.extend(f"{path}: change bundle is missing state.json" for path in orphan_dirs) + return states, errors + + +def changed_files( + root: Path, base: str | None, worktree: bool, two_dot: bool = False +) -> list[str]: + commands: list[list[str]] = [] + if base: + revision_range = f"{base}..HEAD" if two_dot else f"{base}...HEAD" + commands.append( + ["git", "diff", "--no-renames", "--name-only", "--diff-filter=ACMDT", revision_range] + ) + elif worktree: + commands.extend( + ( + ["git", "diff", "--no-renames", "--name-only", "--diff-filter=ACMDT", "HEAD"], + ["git", "ls-files", "--others", "--exclude-standard"], + ) + ) + else: + return [] + paths: set[str] = set() + for command in commands: + result = subprocess.run(command, cwd=root, check=True, capture_output=True, text=True) + paths.update(line for line in result.stdout.splitlines() if line) + return sorted(paths) + + +def enforce_changed_files(paths: list[str], states: dict[Path, dict]) -> list[str]: + governed = [path for path in paths if is_governed_path(path)] + if not governed: + return [] + changed_bundle_dirs = { + Path(*Path(path).parts[:4]) + for path in paths + if len(Path(path).parts) >= 5 + and Path(path).parts[:3] == ("docs", "sdlc", "changes") + } + changed_states = { + path: state + for path, state in states.items() + if path.parent in changed_bundle_dirs + } + if not changed_states: + preview = ", ".join(governed[:5]) + return [f"governed changes require a changed SDLC bundle; governed paths: {preview}"] + verified_index = STATUSES.index("verified") + verified_states = [ + state + for state in changed_states.values() + if state.get("status") in STATUSES + and STATUSES.index(state["status"]) >= verified_index + ] + if not verified_states: + return ["at least one changed SDLC bundle must have status verified or later"] + covering_states = { + path: [ + state + for state in verified_states + if any( + path_is_in_scope(path, scope) + for scope in state.get("governedPaths", []) + if isinstance(scope, str) + ) + ] + for path in governed + } + uncovered = [path for path, covering in covering_states.items() if not covering] + if uncovered: + preview = ", ".join(uncovered[:5]) + return [f"verified SDLC bundles do not cover governed paths: {preview}"] + insufficient = [ + path + for path, covering in covering_states.items() + if not any( + state.get("risk") in RISKS + and RISKS.index(state["risk"]) >= RISKS.index(minimum_risk(path)) + for state in covering + ) + ] + if insufficient: + preview = ", ".join( + f"{path} (requires {minimum_risk(path)})" for path in insufficient[:5] + ) + return [f"verified SDLC bundles have insufficient risk classification: {preview}"] + return [] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("validate",), nargs="?", default="validate") + source = parser.add_mutually_exclusive_group() + source.add_argument("--base", help="compare HEAD with this Git base") + source.add_argument("--push-base", help="compare HEAD directly with the previous push SHA") + source.add_argument("--worktree", action="store_true", help="check staged, unstaged, and untracked files") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + root = Path(__file__).resolve().parent.parent + states, errors = validate_repository(root) + try: + paths = changed_files(root, args.base or args.push_base, args.worktree, bool(args.push_base)) + except subprocess.CalledProcessError as error: + print(error.stderr, file=sys.stderr) + return error.returncode + errors.extend(enforce_changed_files(paths, states)) + if errors: + for error in errors: + print(f"error: {error}", file=sys.stderr) + return 1 + suffix = f"; checked {len(paths)} changed paths" if paths else "" + print(f"SDLC validation passed ({len(states)} change bundles{suffix}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sdlc_policy.py b/scripts/sdlc_policy.py new file mode 100644 index 0000000..daa33f4 --- /dev/null +++ b/scripts/sdlc_policy.py @@ -0,0 +1,63 @@ +"""Stable path, risk, state, and artifact policy for Utter's SDLC validator.""" + +STATUSES = ("intent", "designed", "planned", "implementing", "verified", "released", "closed") +RISKS = ("low", "medium", "high") + +REQUIRED_HEADINGS = { + "intent": ( + "## Problem", "## Outcome", "## Scope", "## Constraints", + "## Acceptance criteria", "## Open questions", + ), + "spec": ( + "## Context", "## Design", "## Safety and failure modes", + "## Test strategy", "## Rollout and rollback", + ), + "plan": ("## Work items", "## Verification plan", "## Human gates"), + "verification": ( + "## Evidence", "## Acceptance criteria", "## Residual risk", "## Decision", + ), +} + + +def is_governed_path(path: str) -> bool: + if path.startswith("docs/sdlc/changes/") or path.startswith("docs/research/"): + return False + if path in {"Package.swift", "Package.resolved", ".gitignore", "AGENTS.md", "CLAUDE.md"}: + return True + return path.startswith( + ( + "Sources/", "SourcesCLI/", "Resources/", "Tests/", "scripts/", + ".github/", "docs/sdlc/", "docs/index.html", "docs/assets/", + ) + ) + + +def minimum_risk(path: str) -> str: + high_risk_paths = { + "Resources/Info.plist", "Resources/OpenType.entitlements", "scripts/build-app.sh", + "scripts/build-version.sh", "scripts/ci-basic-checks.sh", + "scripts/create-signing-cert.sh", "scripts/sdlc.py", + "scripts/release-version.sh", "scripts/sdlc_policy.py", "scripts/verify-release-artifact.sh", + } + high_risk_prefixes = ( + ".github/workflows/", "Sources/Audio/", "Sources/Integration/", + "Sources/Hotkey/", "Sources/LLM/", "Sources/Output/", "Sources/Screen/", + ) + high_risk_source_files = { + "Sources/App/VoicePipeline+CorrectionCapture.swift", + "Sources/App/VoicePipeline+ScreenContext.swift", + "Sources/Config/AppSettings.swift", + "Sources/Speech/AppleSpeechEngine.swift", + } + if path in high_risk_paths or path in high_risk_source_files or path.startswith(high_risk_prefixes): + return "high" + if path in {"Package.swift", "Package.resolved", ".gitignore", "AGENTS.md", "CLAUDE.md"}: + return "medium" + if path.startswith( + ( + ".github/", "Sources/", "SourcesCLI/", "Resources/", + "docs/sdlc/", "docs/index.html", "docs/assets/", "scripts/", + ) + ): + return "medium" + return "low" diff --git a/scripts/tests/test_build_version.sh b/scripts/tests/test_build_version.sh new file mode 100755 index 0000000..bb17d74 --- /dev/null +++ b/scripts/tests/test_build_version.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +VALIDATOR="$SCRIPT_DIR/build-version.sh" +FIXTURE="$(mktemp -d)" +trap 'rm -r "$FIXTURE"' EXIT + +git -C "$FIXTURE" init -q +[ "$($VALIDATOR "$FIXTURE")" = "0.0.0" ] + +git -C "$FIXTURE" -c user.name=Test -c user.email=test@example.com \ + commit --allow-empty -qm base +git -C "$FIXTURE" tag v1.2.3 +[ "$($VALIDATOR "$FIXTURE")" = "1.2.3" ] + +git -C "$FIXTURE" -c user.name=Test -c user.email=test@example.com \ + commit --allow-empty -qm prerelease +git -C "$FIXTURE" tag v1.2.3-beta +[ "$($VALIDATOR "$FIXTURE")" = "0.0.0" ] + +echo "Build version tests passed." diff --git a/scripts/tests/test_release_version.sh b/scripts/tests/test_release_version.sh new file mode 100755 index 0000000..60c4067 --- /dev/null +++ b/scripts/tests/test_release_version.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +VALIDATOR="$SCRIPT_DIR/release-version.sh" +REPOSITORY="$(cd "$SCRIPT_DIR/.." && pwd)" +WORKFLOW="$REPOSITORY/.github/workflows/release.yml" + +for tag in v0.0.0 v1.2.3 v10.20.300; do + expected="${tag#v}" + actual="$($VALIDATOR "$tag")" + [ "$actual" = "$expected" ] || exit 1 +done + +for tag in 1.2.3 v01.2.3 v1.02.3 v1.2.03 v1.2 v1.2.3-beta v1.2.3+build; do + if "$VALIDATOR" "$tag" >/dev/null 2>&1; then + echo "error: invalid release tag accepted: $tag" >&2 + exit 1 + fi +done + +grep -Fq 'SIGNING_MODE=self-signed' "$WORKFLOW" +[ "$(grep -Fc 'VERIFY_ARGS+=(--require-self-signed)' "$WORKFLOW")" -eq 2 ] +grep -Fq 'VERIFY_ARGS+=(--require-developer-id --require-notarization)' "$WORKFLOW" +[ "$(grep -Fc -- '--expected-cert-sha256 "$SIGN_CERT_SHA256"' "$WORKFLOW")" -eq 2 ] +grep -Fq "if: env.SIGNING_MODE == 'developer-id'" "$WORKFLOW" +grep -Fq 'This release is signed with the project self-signed certificate' "$WORKFLOW" +if grep -Eq -- '--clobber|--sign=-|will use ad-hoc' "$WORKFLOW"; then + echo "error: release workflow can replace assets or fall back to ad-hoc signing" >&2 + exit 1 +fi + +echo "Release version tests passed." diff --git a/scripts/tests/test_sdlc.py b/scripts/tests/test_sdlc.py new file mode 100755 index 0000000..13d55c0 --- /dev/null +++ b/scripts/tests/test_sdlc.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 + +import importlib.util +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).parents[1] / "sdlc.py" +sys.path.insert(0, str(SCRIPT_PATH.parent)) +SPEC = importlib.util.spec_from_file_location("sdlc", SCRIPT_PATH) +assert SPEC and SPEC.loader +SDLC = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SDLC) + + +CONTENT = { + "intent": """# Intent: Test +## Problem +Problem. +## Outcome +Outcome. +## Scope +Scope. +## Constraints +Constraints. +## Acceptance criteria +- It passes. +## Open questions +None. +""", + "spec": """# Spec: Test +## Context +Context. +## Design +Design. +## Safety and failure modes +Failures. +## Test strategy +Tests. +## Rollout and rollback +Rollback. +""", + "plan": """# Plan: Test +## Work items +- [x] Work. +## Verification plan +- [x] Verify. +## Human gates +Review. +""", + "verification": """# Verification: Test +## Evidence +Passed. +## Acceptance criteria +- Passed. +## Residual risk +None. +## Decision +Ready for review. +""", +} + + +class SDLCValidationTests(unittest.TestCase): + def make_bundle(self, root: Path, *, risk: str = "high", status: str = "verified", omit: str | None = None) -> Path: + bundle_id = "2026-08-25-test-change" + bundle = root / "docs" / "sdlc" / "changes" / bundle_id + bundle.mkdir(parents=True) + artifacts = {} + for name, content in CONTENT.items(): + if name == omit: + continue + filename = f"{name}.md" + artifacts[name] = filename + (bundle / filename).write_text(content, encoding="utf-8") + state = { + "schemaVersion": 1, + "id": bundle_id, + "title": "Test change", + "risk": risk, + "status": status, + "owners": ["maintainer"], + "acceptanceCriteria": ["It passes."], + "governedPaths": ["Sources/App/"], + "artifacts": artifacts, + } + state_path = bundle / "state.json" + state_path.write_text(json.dumps(state), encoding="utf-8") + return state_path + + def test_verified_high_risk_bundle_is_valid(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + state_path = self.make_bundle(root) + state, errors = SDLC.validate_bundle(state_path) + self.assertEqual(state["status"], "verified") + self.assertEqual(errors, []) + + def test_high_risk_bundle_requires_spec(self) -> None: + with tempfile.TemporaryDirectory() as directory: + state_path = self.make_bundle(Path(directory), omit="spec") + _, errors = SDLC.validate_bundle(state_path) + self.assertTrue(any("spec" in error for error in errors)) + + def test_required_artifacts_must_be_distinct_files(self) -> None: + with tempfile.TemporaryDirectory() as directory: + state_path = self.make_bundle(Path(directory)) + state = json.loads(state_path.read_text(encoding="utf-8")) + combined = state_path.parent / "combined.md" + combined.write_text("\n".join(CONTENT.values()), encoding="utf-8") + alias = state_path.parent / "alias.md" + alias.symlink_to(combined.name) + state["artifacts"] = { + "intent": combined.name, + "spec": f"./{combined.name}", + "plan": str(combined.resolve()), + "verification": alias.name, + } + state_path.write_text(json.dumps(state), encoding="utf-8") + _, errors = SDLC.validate_bundle(state_path) + self.assertTrue(any("distinct files" in error for error in errors)) + self.assertTrue(any("safe relative path" in error for error in errors)) + self.assertTrue(any("symlinks" in error for error in errors)) + + def test_source_change_requires_changed_bundle(self) -> None: + errors = SDLC.enforce_changed_files(["Sources/App/AppState.swift"], {}) + self.assertEqual(len(errors), 1) + + def test_verified_state_satisfies_governed_change(self) -> None: + state_path = Path("docs/sdlc/changes/2026-08-25-test-change/state.json") + paths = [ + "Sources/App/AppState.swift", + "docs/sdlc/changes/2026-08-25-test-change/verification.md", + ] + states = { + state_path: { + "status": "verified", + "risk": "medium", + "governedPaths": ["Sources/App/"], + } + } + self.assertEqual(SDLC.enforce_changed_files(paths, states), []) + + def test_unrelated_verified_bundle_does_not_cover_change(self) -> None: + state_path = Path("docs/sdlc/changes/2026-08-25-test-change/state.json") + paths = [ + "Resources/OpenType.entitlements", + "docs/sdlc/changes/2026-08-25-test-change/verification.md", + ] + states = { + state_path: { + "status": "verified", + "risk": "low", + "governedPaths": ["Sources/App/"], + } + } + errors = SDLC.enforce_changed_files(paths, states) + self.assertTrue(any("do not cover" in error for error in errors)) + + def test_release_workflow_requires_high_risk_bundle(self) -> None: + state_path = Path("docs/sdlc/changes/2026-08-25-test-change/state.json") + paths = [ + ".github/workflows/release.yml", + "docs/sdlc/changes/2026-08-25-test-change/verification.md", + ] + states = { + state_path: { + "status": "verified", + "risk": "medium", + "governedPaths": [".github/workflows/"], + } + } + errors = SDLC.enforce_changed_files(paths, states) + self.assertTrue(any("insufficient risk" in error for error in errors)) + + def test_ui_and_privacy_paths_have_documented_minimum_risk(self) -> None: + self.assertEqual(SDLC.minimum_risk("Sources/UI/SettingsView.swift"), "medium") + self.assertEqual(SDLC.minimum_risk("Sources/Speech/WhisperEngine.swift"), "medium") + self.assertEqual(SDLC.minimum_risk("Sources/Screen/ScreenOCR.swift"), "high") + self.assertEqual(SDLC.minimum_risk("Sources/LLM/RemoteLLMClient.swift"), "high") + self.assertEqual(SDLC.minimum_risk("Sources/Config/AppSettings.swift"), "high") + self.assertEqual(SDLC.minimum_risk("Sources/Hotkey/HotkeyManager.swift"), "high") + self.assertEqual(SDLC.minimum_risk("Sources/Speech/AppleSpeechEngine.swift"), "high") + self.assertEqual(SDLC.minimum_risk("Resources/Info.plist"), "high") + + def test_worktree_rename_keeps_governed_source_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "scripts").mkdir() + (root / "docs" / "research").mkdir(parents=True) + source = root / "scripts" / "guard.sh" + source.write_text("guard\n", encoding="utf-8") + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "."], cwd=root, check=True) + subprocess.run( + ["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "base"], + cwd=root, + check=True, + ) + subprocess.run( + ["git", "mv", "scripts/guard.sh", "docs/research/guard.md"], + cwd=root, + check=True, + ) + paths = SDLC.changed_files(root, base=None, worktree=True) + self.assertIn("scripts/guard.sh", paths) + + def test_base_diff_includes_deleted_governed_path(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "Tests").mkdir() + removed = root / "Tests" / "RemovedTests.swift" + removed.write_text("test\n", encoding="utf-8") + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "."], cwd=root, check=True) + identity = ["-c", "user.name=Test", "-c", "user.email=test@example.com"] + subprocess.run(["git", *identity, "commit", "-qm", "base"], cwd=root, check=True) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, capture_output=True, text=True + ).stdout.strip() + removed.unlink() + subprocess.run(["git", "add", "-u"], cwd=root, check=True) + subprocess.run(["git", *identity, "commit", "-qm", "delete"], cwd=root, check=True) + paths = SDLC.changed_files(root, base=base, worktree=False) + self.assertIn("Tests/RemovedTests.swift", paths) + + def test_push_diff_handles_divergent_history(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "Tests").mkdir() + removed = root / "Tests" / "RemovedTests.swift" + removed.write_text("test\n", encoding="utf-8") + subprocess.run(["git", "init", "-q"], cwd=root, check=True) + subprocess.run(["git", "add", "."], cwd=root, check=True) + identity = ["-c", "user.name=Test", "-c", "user.email=test@example.com"] + subprocess.run(["git", *identity, "commit", "-qm", "base"], cwd=root, check=True) + base = subprocess.run( + ["git", "rev-parse", "HEAD"], cwd=root, check=True, capture_output=True, text=True + ).stdout.strip() + subprocess.run(["git", "checkout", "--orphan", "rewrite", "-q"], cwd=root, check=True) + removed.unlink() + (root / "README.md").write_text("rewrite\n", encoding="utf-8") + subprocess.run(["git", "add", "-A"], cwd=root, check=True) + subprocess.run(["git", *identity, "commit", "-qm", "rewrite"], cwd=root, check=True) + paths = SDLC.changed_files(root, base=base, worktree=False, two_dot=True) + self.assertIn("Tests/RemovedTests.swift", paths) + + def test_research_only_change_uses_fast_path(self) -> None: + paths = ["docs/research/finding.md"] + self.assertEqual(SDLC.enforce_changed_files(paths, {}), []) + + def test_sdlc_policy_change_is_governed(self) -> None: + errors = SDLC.enforce_changed_files(["docs/sdlc/README.md"], {}) + self.assertEqual(len(errors), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-release-artifact.sh b/scripts/verify-release-artifact.sh new file mode 100755 index 0000000..120c78f --- /dev/null +++ b/scripts/verify-release-artifact.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# Verify the assembled Utter app and optional DMG. Distribution checks are opt-in. + +set -euo pipefail + +APP_PATH="" +DMG_PATH="" +EXPECTED_VERSION="" +EXPECTED_CERT_SHA256="" +REQUIRE_DEVELOPER_ID=false +REQUIRE_SELF_SIGNED=false +REQUIRE_NOTARIZATION=false + +usage() { + echo "Usage: $0 --app PATH --version X.Y.Z [--dmg PATH] [--expected-cert-sha256 HEX] [--require-developer-id|--require-self-signed] [--require-notarization]" +} + +fail() { + echo "error: $*" >&2 + exit 1 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --app) APP_PATH="${2:-}"; shift 2 ;; + --dmg) DMG_PATH="${2:-}"; shift 2 ;; + --version) EXPECTED_VERSION="${2:-}"; shift 2 ;; + --expected-cert-sha256) EXPECTED_CERT_SHA256="${2:-}"; shift 2 ;; + --require-developer-id) REQUIRE_DEVELOPER_ID=true; shift ;; + --require-self-signed) REQUIRE_SELF_SIGNED=true; shift ;; + --require-notarization) REQUIRE_NOTARIZATION=true; shift ;; + --help|-h) usage; exit 0 ;; + *) usage >&2; fail "unknown argument: $1" ;; + esac +done + +[ -n "$APP_PATH" ] || fail "--app is required" +[ -n "$EXPECTED_VERSION" ] || fail "--version is required" +[ -d "$APP_PATH" ] || fail "app bundle not found: $APP_PATH" +validated_version="$("$(dirname "$0")/release-version.sh" "v$EXPECTED_VERSION")" \ + || fail "invalid release version: $EXPECTED_VERSION" +[ "$validated_version" = "$EXPECTED_VERSION" ] || fail "release version changed during validation" +if [ "$REQUIRE_NOTARIZATION" = true ] && [ -z "$DMG_PATH" ]; then + fail "--require-notarization also requires --dmg" +fi +if [ "$REQUIRE_DEVELOPER_ID" = true ] && [ "$REQUIRE_SELF_SIGNED" = true ]; then + fail "signing requirements are mutually exclusive" +fi +if [ "$REQUIRE_NOTARIZATION" = true ] && [ "$REQUIRE_DEVELOPER_ID" != true ]; then + fail "--require-notarization also requires --require-developer-id" +fi +if [ -n "$EXPECTED_CERT_SHA256" ]; then + EXPECTED_CERT_SHA256="$(tr '[:lower:]' '[:upper:]' <<<"$EXPECTED_CERT_SHA256")" + [[ "$EXPECTED_CERT_SHA256" =~ ^[A-F0-9]{64}$ ]] \ + || fail "--expected-cert-sha256 must be 64 hexadecimal characters" +fi + +certificate_sha256() { + local app="$1" + local cert_dir cert_prefix fingerprint + cert_dir="$(mktemp -d)" + cert_prefix="$cert_dir/cert" + if ! codesign -d --extract-certificates="$cert_prefix" "$app" >/dev/null 2>&1; then + rm -r "$cert_dir" + return 1 + fi + if [ ! -s "${cert_prefix}0" ]; then + rm -r "$cert_dir" + return 1 + fi + fingerprint="$(openssl x509 -inform DER -in "${cert_prefix}0" -noout \ + -fingerprint -sha256 | cut -d= -f2 | tr -d ':' | tr '[:lower:]' '[:upper:]')" + rm -r "$cert_dir" + printf '%s\n' "$fingerprint" +} + +verify_app() { + local app="$1" + local plist="$app/Contents/Info.plist" + local executable="$app/Contents/MacOS/Utter" + local helper="$app/Contents/MacOS/opentype-cli" + local resources="$app/Contents/Resources" + local product_resources="$resources/OpenType_OpenType.bundle/Contents/Resources" + + [ -f "$plist" ] || fail "missing Info.plist in $app" + [ -x "$executable" ] || fail "missing executable in $app" + [ -x "$helper" ] || fail "missing CLI helper in $app" + [ -s "$resources/Assets.car" ] || fail "missing compiled AppIcon asset catalog in $app" + find "$resources" -name default.metallib -type f -size +0 -print -quit | grep -q . \ + || fail "missing compiled MLX Metal library in $app" + [ -s "$product_resources/en.lproj/Localizable.strings" ] \ + || fail "missing English localization in $app" + [ -s "$product_resources/zh-Hans.lproj/Localizable.strings" ] \ + || fail "missing Simplified Chinese localization in $app" + [ -f "$product_resources/Sounds/start.caf" ] || fail "missing start sound in $app" + [ -f "$product_resources/Sounds/stop.caf" ] || fail "missing stop sound in $app" + [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$plist")" = "com.opentype.voiceinput" ] \ + || fail "unexpected bundle identifier in $app" + [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist")" = "$EXPECTED_VERSION" ] \ + || fail "unexpected short version in $app" + [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$plist")" = "$EXPECTED_VERSION" ] \ + || fail "unexpected build version in $app" + [ "$(lipo -archs "$executable")" = "arm64" ] || fail "Utter executable must be arm64-only" + [ "$(lipo -archs "$helper")" = "arm64" ] || fail "CLI helper must be arm64-only" + codesign --verify --deep --strict --verbose=2 "$app" + + if [ "$REQUIRE_DEVELOPER_ID" = true ] || [ "$REQUIRE_SELF_SIGNED" = true ]; then + local signature + signature="$(codesign -dvvv "$app" 2>&1)" + grep -Eq '^CodeDirectory .*flags=.*\(.*runtime.*\)' <<<"$signature" \ + || fail "app signature does not enable the hardened runtime" + fi + + if [ "$REQUIRE_DEVELOPER_ID" = true ]; then + grep -q '^Authority=Developer ID Application:' <<<"$signature" \ + || fail "app is not signed with a Developer ID Application identity" + grep -Eq '^TeamIdentifier=[A-Z0-9]+$' <<<"$signature" \ + || fail "app signature has no Apple team identifier" + fi + + if [ "$REQUIRE_SELF_SIGNED" = true ]; then + grep -Eq '^Authority=.+$' <<<"$signature" \ + || fail "app has no self-signed authority" + grep -q '^TeamIdentifier=not set$' <<<"$signature" \ + || fail "self-signed app unexpectedly has an Apple team identifier" + fi + + if [ -n "$EXPECTED_CERT_SHA256" ]; then + local actual_cert_sha256 + actual_cert_sha256="$(certificate_sha256 "$app")" \ + || fail "could not extract the app signing certificate" + [ "$actual_cert_sha256" = "$EXPECTED_CERT_SHA256" ] \ + || fail "app signing certificate does not match the imported certificate" + fi +} + +compare_app_bundles() { + local expected="$1" + local actual="$2" + local relative expected_path actual_path + + diff -u \ + <(cd "$expected" && find . -mindepth 1 -print | LC_ALL=C sort) \ + <(cd "$actual" && find . -mindepth 1 -print | LC_ALL=C sort) \ + || fail "DMG app bundle path manifest differs from the built app" + + while IFS= read -r relative; do + expected_path="$expected/${relative#./}" + actual_path="$actual/${relative#./}" + [ "$(stat -f '%Lp' "$expected_path")" = "$(stat -f '%Lp' "$actual_path")" ] \ + || fail "DMG app bundle permissions differ: $relative" + if [ -L "$expected_path" ]; then + [ -L "$actual_path" ] && [ "$(readlink "$expected_path")" = "$(readlink "$actual_path")" ] \ + || fail "DMG app bundle symlink differs: $relative" + elif [ -f "$expected_path" ]; then + [ -f "$actual_path" ] && cmp "$expected_path" "$actual_path" \ + || fail "DMG app bundle file differs: $relative" + elif [ ! -d "$expected_path" ]; then + fail "unsupported app bundle entry: $relative" + fi + done < <(cd "$expected" && find . -mindepth 1 -print | LC_ALL=C sort) +} + +verify_app "$APP_PATH" + +if [ -n "$DMG_PATH" ]; then + [ -f "$DMG_PATH" ] || fail "DMG not found: $DMG_PATH" + hdiutil verify "$DMG_PATH" >/dev/null + + mount_point="$(mktemp -d)" + mounted=false + cleanup() { + if [ "$mounted" = true ]; then + hdiutil detach "$mount_point" -quiet || true + fi + rmdir "$mount_point" 2>/dev/null || true + } + trap cleanup EXIT + hdiutil attach "$DMG_PATH" -readonly -nobrowse -mountpoint "$mount_point" -quiet + mounted=true + verify_app "$mount_point/Utter.app" + compare_app_bundles "$APP_PATH" "$mount_point/Utter.app" + hdiutil detach "$mount_point" -quiet + mounted=false + rmdir "$mount_point" + trap - EXIT + + if [ "$REQUIRE_NOTARIZATION" = true ]; then + xcrun stapler validate "$DMG_PATH" + spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG_PATH" + spctl --assess --type execute --verbose=2 "$APP_PATH" + fi +fi + +echo "Release artifact verification passed."