diff --git a/.engineering/commands.json b/.engineering/commands.json index 9312f34..4528cee 100644 --- a/.engineering/commands.json +++ b/.engineering/commands.json @@ -13,6 +13,7 @@ "package": {"status": "optional", "run": "bash scripts/build_artifact.sh --dmg"}, "local_real_environment": {"status": "recommended", "run": "python3 scripts/run_local_real_environment_suite.py"}, "release_build": {"status": "required", "run": "python3 scripts/build_production_artifact.py"}, + "release_production_artifact": {"status": "required", "run": "gh workflow run production-release-artifact.yml --ref main -f tag= -f source_revision="}, "release_evidence": {"status": "required", "run": "python3 scripts/measured_release_target_mac.py --app && python3 scripts/record_while_ai_busy_target_mac.py --app "}, "release_prepare": {"status": "required", "run": "python3 scripts/prepare_github_release.py --artifact-dir --tag --source-revision --notes-source docs/releases/.md --release-state --output-dir dist/github-release/"}, "release_draft": {"status": "required", "run": "gh workflow run draft-release.yml --ref main -f tag= -f source_revision= -f artifact_run_id= -f release_state="}, diff --git a/.github/workflows/production-release-artifact.yml b/.github/workflows/production-release-artifact.yml new file mode 100644 index 0000000..45fa887 --- /dev/null +++ b/.github/workflows/production-release-artifact.yml @@ -0,0 +1,291 @@ +name: Production release artifact + +on: + workflow_dispatch: + inputs: + tag: + description: Existing release tag in vX.Y.Z form + required: true + type: string + source_revision: + description: Exact 40-character tagged SHA in main history + required: true + type: string + +permissions: + contents: read + +concurrency: + group: closedroom-production-release-${{ inputs.source_revision }} + cancel-in-progress: false + +jobs: + production-artifact: + runs-on: macos-14 + timeout-minutes: 90 + environment: production-release + env: + DEVELOPER_DIR: /Applications/Xcode_16.2.app/Contents/Developer + CLOSEDROOM_BUILD_PYTHON_VERSION: "3.12" + steps: + - name: Require main dispatch authority + env: + SOURCE_REVISION: ${{ inputs.source_revision }} + shell: bash + run: | + set -euo pipefail + [[ "$GITHUB_REF" == "refs/heads/main" ]] || { + echo "production artifact workflow must be dispatched from main" >&2; exit 1; + } + [[ "$SOURCE_REVISION" =~ ^[0-9a-f]{40}$ ]] || { + echo "source_revision must be a full lowercase commit SHA" >&2; exit 1; + } + [[ "$(uname -m)" == "arm64" ]] || { + echo "production artifact build requires arm64" >&2; exit 1; + } + + - name: Check out exact production source + uses: actions/checkout@v4 + with: + ref: ${{ inputs.source_revision }} + fetch-depth: 0 + + - name: Verify stable tag and canonical product version + env: + TAG: ${{ inputs.tag }} + SOURCE_REVISION: ${{ inputs.source_revision }} + shell: bash + run: | + set -euo pipefail + [[ "$TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { + echo "tag must use vX.Y.Z" >&2; exit 1; + } + test "$(git rev-parse HEAD)" = "$SOURCE_REVISION" + git fetch origin main --tags --force + test "$(git rev-parse "refs/tags/${TAG}^{commit}")" = "$SOURCE_REVISION" + git merge-base --is-ancestor "$SOURCE_REVISION" origin/main + test -z "$(git status --porcelain)" + python3 scripts/product_version.py --root . --expect-tag "$TAG" + + - name: Verify Swift 6 production toolchain + shell: bash + run: | + set -euo pipefail + test -d "$DEVELOPER_DIR" + xcodebuild -version + swift --version | grep -Eq 'Swift version 6\.' + xcrun notarytool --version + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install production build toolchain + shell: bash + run: | + set -euo pipefail + brew list uv >/dev/null 2>&1 || brew install uv + npm install --global pnpm@9 + brew list ffmpeg >/dev/null 2>&1 || brew install ffmpeg + brew list librsvg >/dev/null 2>&1 || brew install librsvg + + - name: Import protected Apple release authority + env: + P12_BASE64: ${{ secrets.CLOSEDROOM_DEVELOPER_ID_P12_BASE64 }} + P12_PASSWORD: ${{ secrets.CLOSEDROOM_DEVELOPER_ID_P12_PASSWORD }} + NOTARY_KEY_BASE64: ${{ secrets.CLOSEDROOM_NOTARY_API_KEY_BASE64 }} + NOTARY_KEY_ID: ${{ secrets.CLOSEDROOM_NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.CLOSEDROOM_NOTARY_ISSUER_ID }} + shell: bash + run: | + set -euo pipefail + for value in P12_BASE64 P12_PASSWORD NOTARY_KEY_BASE64 NOTARY_KEY_ID NOTARY_ISSUER_ID; do + [[ -n "${!value:-}" ]] || { echo "required protected release secret is missing: $value" >&2; exit 1; } + done + + KEYCHAIN_PATH="$RUNNER_TEMP/closedroom-production-release.keychain-db" + CERTIFICATE_PATH="$RUNNER_TEMP/closedroom-developer-id.p12" + API_KEY_PATH="$RUNNER_TEMP/closedroom-notary-api-key.p8" + NOTARY_PROFILE="closedroom-production-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + KEYCHAIN_PASSWORD="$(openssl rand -hex 32)" + export CERTIFICATE_PATH API_KEY_PATH + + python3 - <<'PY' + import base64 + import os + from pathlib import Path + + Path(os.environ["CERTIFICATE_PATH"]).write_bytes( + base64.b64decode(os.environ["P12_BASE64"], validate=True) + ) + Path(os.environ["API_KEY_PATH"]).write_bytes( + base64.b64decode(os.environ["NOTARY_KEY_BASE64"], validate=True) + ) + PY + chmod 600 "$CERTIFICATE_PATH" "$API_KEY_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" \ + -P "$P12_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_PATH" + security set-key-partition-list \ + -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + + IDENTITIES="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | sed -nE 's/.*"(Developer ID Application:[^"]+)".*/\1/p')" + IDENTITY_COUNT="$(printf '%s\n' "$IDENTITIES" | sed '/^$/d' | wc -l | tr -d ' ')" + [[ "$IDENTITY_COUNT" == "1" ]] || { + echo "expected exactly one Developer ID Application identity, found $IDENTITY_COUNT" >&2; exit 1; + } + SIGNING_IDENTITY="$(printf '%s\n' "$IDENTITIES" | head -n 1)" + + xcrun notarytool store-credentials "$NOTARY_PROFILE" \ + --key "$API_KEY_PATH" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER_ID" \ + --keychain "$KEYCHAIN_PATH" + + { + echo "CLOSEDROOM_SIGN_IDENTITY=$SIGNING_IDENTITY" + echo "CLOSEDROOM_NOTARY_KEYCHAIN_PROFILE=$NOTARY_PROFILE" + echo "CLOSEDROOM_NOTARY_KEYCHAIN=$KEYCHAIN_PATH" + echo "CLOSEDROOM_BUILD_ID=github-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + } >> "$GITHUB_ENV" + + - name: Build signed and notarized immutable artifact + env: + UV_CACHE_DIR: .cache/uv + shell: bash + run: | + set -euo pipefail + python3 scripts/build_production_artifact.py --root . + + - name: Verify production artifact inventory + id: verify + env: + SOURCE_REVISION: ${{ inputs.source_revision }} + shell: bash + run: | + set -euo pipefail + ARTIFACT_DIR="dist/artifacts/macos-arm64-release-package/${CLOSEDROOM_BUILD_ID}" + test -d "$ARTIFACT_DIR" + echo "artifact_dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT" + + python3 - "$ARTIFACT_DIR" "$SOURCE_REVISION" "$CLOSEDROOM_BUILD_ID" <<'PY' + from __future__ import annotations + + import hashlib + import json + from pathlib import Path + import sys + + artifact_dir = Path(sys.argv[1]) + revision = sys.argv[2] + build_id = sys.argv[3] + + def load_json(name: str) -> dict: + path = artifact_dir / name + if not path.is_file(): + raise SystemExit(f"missing production artifact metadata: {path}") + return json.loads(path.read_text(encoding="utf-8")) + + def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + manifest = load_json("build-manifest.json") + evidence = load_json("production-release-evidence.json") + changelog = artifact_dir / "BUILD_CHANGELOG.md" + checksums = artifact_dir / "SHA256SUMS" + if not changelog.is_file() or not checksums.is_file(): + raise SystemExit("production artifact is missing changelog or checksums") + + if manifest.get("status") != "successful": + raise SystemExit("production manifest is not successful") + if manifest.get("build_id") != build_id: + raise SystemExit("production manifest build id mismatch") + source = manifest.get("source") or {} + if source.get("revision") != revision or source.get("dirty") is not False: + raise SystemExit("production manifest source identity mismatch") + lineage = manifest.get("lineage") or {} + expected_lineage = { + "platform": "macos", + "architecture": "arm64", + "channel": "release", + "variant": "package", + } + for key, expected in expected_lineage.items(): + if lineage.get(key) != expected: + raise SystemExit(f"production manifest lineage mismatch: {key}") + if (manifest.get("configuration") or {}).get("signing") != "developer-id-notarized": + raise SystemExit("production manifest signing is not release-ready") + + expected_evidence = { + "status": "pass", + "source_revision": revision, + "build_id": build_id, + "signing": "developer-id", + "secure_timestamp": True, + "app_notarization": "accepted", + "app_stapler_validation": "pass", + "app_gatekeeper_assessment": "pass", + "dmg_notarization": "accepted", + "dmg_stapler_validation": "pass", + "dmg_gatekeeper_assessment": "pass", + "notary_profile_configured": True, + } + for key, expected in expected_evidence.items(): + if evidence.get(key) != expected: + raise SystemExit(f"production evidence mismatch: {key}") + + artifacts = manifest.get("artifacts") or {} + app_meta = artifacts.get("app") or {} + dmg_meta = artifacts.get("dmg") or {} + app = artifact_dir / str(app_meta.get("path") or "") + dmg = artifact_dir / str(dmg_meta.get("path") or "") + if not app.is_dir(): + raise SystemExit("production app bundle is missing") + if not dmg.is_file(): + raise SystemExit("production DMG is missing") + if dmg.stat().st_size != dmg_meta.get("bytes"): + raise SystemExit("production DMG byte count mismatch") + if sha256_file(dmg) != dmg_meta.get("sha256"): + raise SystemExit("production DMG SHA-256 mismatch") + + checksum_lines = set(checksums.read_text(encoding="utf-8").splitlines()) + expected_app_line = f"{app_meta.get('sha256')} {app_meta.get('path')}/" + expected_dmg_line = f"{dmg_meta.get('sha256')} {dmg_meta.get('path')}" + if expected_app_line not in checksum_lines or expected_dmg_line not in checksum_lines: + raise SystemExit("production checksums do not match manifest") + PY + + - name: Upload qualified production artifact + uses: actions/upload-artifact@v4 + with: + name: closedroom-production-release-${{ inputs.source_revision }} + path: | + ${{ steps.verify.outputs.artifact_dir }}/*.dmg + ${{ steps.verify.outputs.artifact_dir }}/build-manifest.json + ${{ steps.verify.outputs.artifact_dir }}/production-release-evidence.json + ${{ steps.verify.outputs.artifact_dir }}/BUILD_CHANGELOG.md + ${{ steps.verify.outputs.artifact_dir }}/SHA256SUMS + if-no-files-found: error + retention-days: 7 + + - name: Remove protected Apple release authority + if: always() + shell: bash + run: | + set +e + security delete-keychain "$RUNNER_TEMP/closedroom-production-release.keychain-db" >/dev/null 2>&1 + rm -f \ + "$RUNNER_TEMP/closedroom-developer-id.p12" \ + "$RUNNER_TEMP/closedroom-notary-api-key.p8" diff --git a/docs/current-state.md b/docs/current-state.md index 94323ec..438aa7e 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -15,7 +15,8 @@ ClosedRoom follows `daniele21/repo-template-sw` **0.10.0**, maturity **L2**, wit - PRS-18 measured product/runtime release evidence remains active. - GitHub release productization separates stable source from binary publication. Root `VERSION` owns product version `0.2.0` independently from Python package metadata. - GRP-3 stages immutable production artifacts with exact version/source/distribution checks, unchanged DMG bytes, canonical names, checksums, notes and inventory. -- GRP-4 defines manual draft-only publication dispatched from `main` for an exact tagged commit in `main` history: trusted same-SHA production workflow artifact, GRP-3 validation and post-upload page/asset verification. It never builds or publishes a final release. +- GRP-4 defines manual draft-only publication from a tagged commit in `main` history using a trusted same-SHA production workflow artifact. It never builds or publishes a final release. +- GRP-5 automation targets the `production-release` environment, keeps Developer ID/notary authority ephemeral, delegates to the canonical production builder, validates Apple evidence and uploads only the trusted same-SHA artifact consumed by GRP-4. Real success still requires the GitHub environment/authority to be configured externally. ## Current integration state @@ -48,12 +49,12 @@ A passing LOCAL REAL_ENVIRONMENT run may close physical product/runtime obligati ## Active workstreams - [`meeting-value-efficiency.md`](workstreams/meeting-value-efficiency.md): PRS-11..17 integrated; PRS-18 measured product/runtime release evidence active. -- [`github-release-productization.md`](workstreams/github-release-productization.md): GRP-1..4 implemented; Apple-qualified production artifact authority and first public release remain blocked. +- [`github-release-productization.md`](workstreams/github-release-productization.md): GRP-1..5 automation implemented; Apple authority/environment configuration and first public release remain blocked. PRS-18 owns product/runtime evidence; GitHub release productization owns version/release/publication mechanics. ## Next highest-value work 1. Complete remaining PRS-18 target-Mac evidence on the exact stable candidate and promote stable source only when RELEASE/FULL evidence agrees. -2. Establish GRP-5 protected production-artifact authority with the canonical workflow/artifact contract; do not weaken Apple qualification if authority is unavailable. +2. Configure the `production-release` GitHub environment with Apple authority when available and run GRP-5 on the exact tagged stable source. 3. Publish GRP-6 only after exact stable source and exact qualified artifact agree; add README download links only after a real GitHub Release exists. diff --git a/docs/workstreams/github-release-productization.md b/docs/workstreams/github-release-productization.md index c3357df..071aad0 100644 --- a/docs/workstreams/github-release-productization.md +++ b/docs/workstreams/github-release-productization.md @@ -2,7 +2,7 @@ Status: ACTIVE Owner: repository release metadata, version identity, publication automation and public release experience -Base: `dev@831469a16fcdd62ba9fff5b2bf5f232ada555128` +Base: `dev@685aad5e6040097631f0c9bb4562ae57c2dc72fa` ## Outcome @@ -49,10 +49,10 @@ The public DMG is a byte-for-byte copy of the qualified production DMG. Staging | GRP-2 | Canonical product version drives bundle/build/release identity | DONE | | GRP-3 | Release notes/categories and public asset naming are contracts | DONE | | GRP-4 | Tag/main-bound workflow creates draft Release from qualified artifacts | DONE | -| GRP-5 | Developer ID/notarization authority produces trusted production artifact | BLOCKED | +| GRP-5 | Production workflow is ready; protected Apple authority can qualify its output | AUTOMATION DONE; AUTHORITY BLOCKED | | GRP-6 | First public GitHub Release from exact stable source | BLOCKED | -GRP-5 is externally blocked until Apple Developer distribution authority exists. GRP-6 depends on GRP-1..5; never weaken signing/notarization truth to bypass that block. +GRP-5 execution is externally blocked until the `production-release` environment and Apple Developer distribution authority are configured. GRP-6 depends on GRP-1..5; never weaken signing/notarization truth to bypass that block. ## GRP-1 — stable source vs distribution @@ -78,7 +78,9 @@ The workflow creates/updates **draft only**, refuses to modify published release ## GRP-5 — Apple distribution authority -Existing production tooling must run with protected Developer ID/notary authority and expose its exact successful output through the canonical `production-release-artifact.yml` / `closedroom-production-release-` contract consumed by GRP-4. Missing authority is `BLOCKED`, not product failure, and must never yield an unsigned artifact labeled stable. +`.github/workflows/production-release-artifact.yml` is manual-only, read-only and targets `production-release`. It accepts an exact tagged SHA in `main` history, delegates production build/sign/notarize/staple/Gatekeeper work to the existing canonical builder, validates the resulting release lineage and uploads only the trusted same-SHA artifact consumed by GRP-4. Run-scoped authority is kept ephemeral and cleaned after execution. + +`release_production_artifact` is the canonical dispatch command. The repository environment protection/secrets and actual Apple authority are external configuration: until present and proven by a successful run, GRP-5 distribution qualification remains `BLOCKED`. ## GRP-6 — first public release @@ -94,7 +96,7 @@ Version/build/workflow changes are FULL because they touch release/build/CI iden ## Resume checkpoint -- GRP-3 integrated through PR #67; current GRP-4 base is `dev@831469a16fcdd62ba9fff5b2bf5f232ada555128`; -- GRP-1..3 are integrated; GRP-4 implementation is complete on `chore/github-draft-release` and now needs exact-head integration validation; +- GRP-1..4 are integrated on `dev@685aad5e6040097631f0c9bb4562ae57c2dc72fa`; GRP-5 automation is implemented on `chore/production-release-artifact` and needs exact-head integration validation; +- real GRP-5 success remains blocked by `production-release` environment configuration plus Apple Developer authority; automation tests are not distribution qualification; - historical `v0.1.0` exists; no GitHub Release exists; next product line is `0.2.0` / `v0.2.0`; -- after GRP-4 integration: complete PRS-18 stable-source evidence and establish GRP-5 protected production-artifact authority before any public binary release. +- stable-source work is independent: complete PRS-18 exact-candidate target-Mac evidence, then promote `dev -> main` before any `v0.2.0` distribution run. diff --git a/scripts/build_production_artifact.py b/scripts/build_production_artifact.py index c012fd6..e6c29fd 100755 --- a/scripts/build_production_artifact.py +++ b/scripts/build_production_artifact.py @@ -52,6 +52,10 @@ def parse_args() -> argparse.Namespace: "--notary-keychain-profile", default=os.getenv("CLOSEDROOM_NOTARY_KEYCHAIN_PROFILE", ""), ) + parser.add_argument( + "--notary-keychain", + default=os.getenv("CLOSEDROOM_NOTARY_KEYCHAIN", ""), + ) parser.add_argument("--build-id", default=os.getenv("CLOSEDROOM_BUILD_ID", "")) parser.add_argument( "--keep", @@ -108,22 +112,25 @@ def restore_generated_frontend(root: Path) -> None: run(["git", "clean", "-fd", "--", target], cwd=root) -def notary_submit(artifact: Path, profile: str, *, cwd: Path) -> dict[str, Any]: - completed = run( - [ - "xcrun", - "notarytool", - "submit", - str(artifact), - "--keychain-profile", - profile, - "--wait", - "--output-format", - "json", - ], - cwd=cwd, - timeout=1800, - ) +def notary_submit( + artifact: Path, + profile: str, + *, + cwd: Path, + keychain: str | None = None, +) -> dict[str, Any]: + command = [ + "xcrun", + "notarytool", + "submit", + str(artifact), + "--keychain-profile", + profile, + ] + if keychain: + command.extend(["--keychain", keychain]) + command.extend(["--wait", "--output-format", "json"]) + completed = run(command, cwd=cwd, timeout=1800) payload = json.loads(completed.stdout or "{}") if str(payload.get("status") or "").lower() != "accepted": raise RuntimeError( @@ -158,6 +165,13 @@ def main() -> int: root = Path(args.root).resolve() identity = str(args.signing_identity or "").strip() profile = str(args.notary_keychain_profile or "").strip() + keychain_raw = str(args.notary_keychain or "").strip() + keychain: str | None = None + if keychain_raw: + keychain_path = Path(keychain_raw).expanduser().resolve() + if not keychain_path.is_file(): + raise SystemExit(f"notary keychain not found: {keychain_path}") + keychain = str(keychain_path) if platform.system() != "Darwin" or platform.machine() != "arm64": raise SystemExit("production artifact build requires a target Apple-Silicon Mac") @@ -252,7 +266,7 @@ def main() -> int: cwd=root, timeout=600, ) - app_notary = notary_submit(app_zip, profile, cwd=root) + app_notary = notary_submit(app_zip, profile, cwd=root, keychain=keychain) run( ["xcrun", "stapler", "staple", str(staging_app)], cwd=root, @@ -284,7 +298,7 @@ def main() -> int: if not staging_dmg.is_file(): raise RuntimeError("DMG creation did not produce an artifact") - dmg_notary = notary_submit(staging_dmg, profile, cwd=root) + dmg_notary = notary_submit(staging_dmg, profile, cwd=root, keychain=keychain) run( ["xcrun", "stapler", "staple", str(staging_dmg)], cwd=root, diff --git a/test/test_production_release_artifact_tooling.py b/test/test_production_release_artifact_tooling.py new file mode 100644 index 0000000..8985192 --- /dev/null +++ b/test/test_production_release_artifact_tooling.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import unittest +from unittest.mock import Mock, patch + + +ROOT = Path(__file__).parents[1] + + +def load(name: str, relative: str): + path = ROOT / relative + spec = importlib.util.spec_from_file_location(name, path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +production = load("production_release_artifact_tooling", "scripts/build_production_artifact.py") + + +class ProductionNotaryKeychainTests(unittest.TestCase): + @patch.object(production, "run") + def test_notary_submit_uses_explicit_file_keychain(self, run: Mock) -> None: + run.return_value = Mock( + stdout=json.dumps({"id": "submission", "status": "Accepted"}) + ) + + result = production.notary_submit( + Path("ClosedRoom.dmg"), + "closedroom-production", + cwd=ROOT, + keychain="/tmp/closedroom-release.keychain-db", + ) + + command = run.call_args.args[0] + self.assertEqual(result["status"], "Accepted") + self.assertEqual( + command, + [ + "xcrun", + "notarytool", + "submit", + "ClosedRoom.dmg", + "--keychain-profile", + "closedroom-production", + "--keychain", + "/tmp/closedroom-release.keychain-db", + "--wait", + "--output-format", + "json", + ], + ) + self.assertEqual(run.call_args.kwargs["timeout"], 1800) + + @patch.object(production, "run") + def test_notary_submit_keeps_existing_profile_behavior_without_keychain( + self, run: Mock + ) -> None: + run.return_value = Mock(stdout=json.dumps({"status": "Accepted"})) + + production.notary_submit( + Path("ClosedRoom.zip"), + "closedroom-production", + cwd=ROOT, + ) + + command = run.call_args.args[0] + self.assertNotIn("--keychain", command) + self.assertEqual( + command, + [ + "xcrun", + "notarytool", + "submit", + "ClosedRoom.zip", + "--keychain-profile", + "closedroom-production", + "--wait", + "--output-format", + "json", + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_production_release_artifact_workflow_contract.py b/test/test_production_release_artifact_workflow_contract.py new file mode 100644 index 0000000..6fb5fb8 --- /dev/null +++ b/test/test_production_release_artifact_workflow_contract.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).parents[1] +WORKFLOW = ROOT / ".github" / "workflows" / "production-release-artifact.yml" + + +class ProductionReleaseArtifactWorkflowContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.workflow = WORKFLOW.read_text(encoding="utf-8") + + def test_is_manual_main_bound_and_read_only(self) -> None: + self.assertIn("workflow_dispatch:", self.workflow) + self.assertIn('refs/heads/main', self.workflow) + self.assertIn('git merge-base --is-ancestor "$SOURCE_REVISION" origin/main', self.workflow) + self.assertNotIn('[[ "$GITHUB_SHA" == "$SOURCE_REVISION" ]]', self.workflow) + self.assertIn("contents: read", self.workflow) + self.assertNotIn("contents: write", self.workflow) + self.assertNotIn("pull_request:", self.workflow) + self.assertNotIn("push:", self.workflow) + + def test_uses_protected_release_environment_and_expected_authority_secrets(self) -> None: + self.assertIn("environment: production-release", self.workflow) + for secret in ( + "CLOSEDROOM_DEVELOPER_ID_P12_BASE64", + "CLOSEDROOM_DEVELOPER_ID_P12_PASSWORD", + "CLOSEDROOM_NOTARY_API_KEY_BASE64", + "CLOSEDROOM_NOTARY_KEY_ID", + "CLOSEDROOM_NOTARY_ISSUER_ID", + ): + self.assertIn(f"secrets.{secret}", self.workflow) + + def test_keeps_signing_and_notary_authority_in_temporary_keychain(self) -> None: + self.assertIn("security create-keychain", self.workflow) + self.assertIn("security import", self.workflow) + self.assertIn("security set-key-partition-list", self.workflow) + self.assertIn("xcrun notarytool store-credentials", self.workflow) + self.assertIn('--keychain "$KEYCHAIN_PATH"', self.workflow) + self.assertIn("CLOSEDROOM_NOTARY_KEYCHAIN=$KEYCHAIN_PATH", self.workflow) + self.assertIn("security delete-keychain", self.workflow) + self.assertIn("Remove protected Apple release authority", self.workflow) + + def test_delegates_build_and_notarization_to_canonical_owner_only(self) -> None: + self.assertIn("python3 scripts/build_production_artifact.py --root .", self.workflow) + self.assertNotIn("bash build.sh", self.workflow) + self.assertNotIn("scripts/build_artifact.sh", self.workflow) + self.assertNotIn("create_dmg.sh", self.workflow) + self.assertNotIn("notarytool submit", self.workflow) + self.assertNotIn("stapler staple", self.workflow) + + def test_verifies_exact_release_lineage_before_upload(self) -> None: + self.assertIn('refs/tags/${TAG}^{commit}', self.workflow) + self.assertIn('scripts/product_version.py --root . --expect-tag "$TAG"', self.workflow) + self.assertIn('"developer-id-notarized"', self.workflow) + self.assertIn('"app_notarization": "accepted"', self.workflow) + self.assertIn('"dmg_notarization": "accepted"', self.workflow) + self.assertIn("production checksums do not match manifest", self.workflow) + + def test_upload_contract_matches_grp4_and_never_publishes(self) -> None: + self.assertIn( + "name: closedroom-production-release-${{ inputs.source_revision }}", + self.workflow, + ) + self.assertIn("production-release-evidence.json", self.workflow) + self.assertIn("build-manifest.json", self.workflow) + self.assertIn("BUILD_CHANGELOG.md", self.workflow) + self.assertIn("SHA256SUMS", self.workflow) + self.assertIn("retention-days: 7", self.workflow) + self.assertNotIn("gh release", self.workflow) + self.assertNotIn("draft-release.yml", self.workflow) + + +if __name__ == "__main__": + unittest.main()