Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .engineering/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -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=<vX.Y.Z> -f source_revision=<exact-tag-sha>"},
"release_evidence": {"status": "required", "run": "python3 scripts/measured_release_target_mac.py --app <exact-production-app> && python3 scripts/record_while_ai_busy_target_mac.py --app <exact-production-app>"},
"release_prepare": {"status": "required", "run": "python3 scripts/prepare_github_release.py --artifact-dir <exact-production-artifact-dir> --tag <vX.Y.Z> --source-revision <exact-tag-sha> --notes-source docs/releases/<vX.Y.Z>.md --release-state <stable|prerelease> --output-dir dist/github-release/<vX.Y.Z>"},
"release_draft": {"status": "required", "run": "gh workflow run draft-release.yml --ref main -f tag=<vX.Y.Z> -f source_revision=<exact-tag-sha> -f artifact_run_id=<canonical-production-artifact-run-id> -f release_state=<stable|prerelease>"},
Expand Down
291 changes: 291 additions & 0 deletions .github/workflows/production-release-artifact.yml
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 4 additions & 3 deletions docs/current-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
16 changes: 9 additions & 7 deletions docs/workstreams/github-release-productization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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-<SHA>` 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

Expand All @@ -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.
Loading
Loading