From 25244c84fc8eae05fa7354eba7471d5db23fab75 Mon Sep 17 00:00:00 2001 From: Eric Burke Date: Mon, 6 Apr 2026 11:19:11 -0700 Subject: [PATCH] Add notarization API key rotation helper --- .../workflows/one-off-mac-notarization.yaml | 162 +++++++ tools/distribution/.gitignore | 1 + tools/distribution/cmd_import_api_key.py | 52 +++ .../cmd_upload_api_key_secrets.py | 51 +++ tools/distribution/rotate_signing_assets.py | 70 +++ tools/distribution/rotation_shared.py | 400 ++++++++++++++++++ 6 files changed, 736 insertions(+) create mode 100644 .github/workflows/one-off-mac-notarization.yaml create mode 100644 tools/distribution/.gitignore create mode 100644 tools/distribution/cmd_import_api_key.py create mode 100644 tools/distribution/cmd_upload_api_key_secrets.py create mode 100755 tools/distribution/rotate_signing_assets.py create mode 100644 tools/distribution/rotation_shared.py diff --git a/.github/workflows/one-off-mac-notarization.yaml b/.github/workflows/one-off-mac-notarization.yaml new file mode 100644 index 0000000000..52827084d5 --- /dev/null +++ b/.github/workflows/one-off-mac-notarization.yaml @@ -0,0 +1,162 @@ +# One-off build for validating Codex CLI macOS signing and notarization. +# Run this in the GitHub UI with "Run workflow", or with the CLI: +# gh workflow run one-off-mac-notarization [--ref ] [-f target=aarch64-apple-darwin] +# Omitting the ref will run the workflow on the default branch. + +name: one-off-mac-notarization +run-name: One-off Codex CLI macOS Notarization + +on: + workflow_dispatch: + inputs: + target: + type: choice + description: "macOS target to build" + required: false + default: "all" + options: + - "all" + - "aarch64-apple-darwin" + - "x86_64-apple-darwin" + sign-dmg: + type: boolean + description: "Build, sign, notarize, and staple the DMG" + required: false + default: true + push: + branches: + - release/codex/mac/one-off-notarization + +permissions: + contents: read + +jobs: + macos-notarization: + name: Build and notarize - ${{ matrix.target }} + runs-on: macos-15-xlarge + timeout-minutes: 60 + defaults: + run: + working-directory: codex-rs + env: + CARGO_PROFILE_RELEASE_LTO: thin + strategy: + fail-fast: false + matrix: + target: ${{ fromJSON((github.event_name == 'workflow_dispatch' && inputs.target != 'all') && format('["{0}"]', inputs.target) || '["aarch64-apple-darwin","x86_64-apple-darwin"]') }} + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Print runner specs + shell: bash + run: | + set -euo pipefail + total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" + echo "Runner: ${RUNNER_NAME:-unknown}" + echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" + echo "Hardware model: $(sysctl -n hw.model)" + echo "CPU architecture: $(uname -m)" + echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" + echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" + echo "Total RAM: ${total_ram}" + echo "Disk usage:" + df -h . + + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + targets: ${{ matrix.target }} + + - name: Cargo build + shell: bash + run: cargo build --target ${{ matrix.target }} --release --timings --bin codex --bin codex-responses-api-proxy + + - name: Sign and notarize macOS binaries + uses: ./.github/actions/macos-code-sign + with: + target: ${{ matrix.target }} + sign-binaries: "true" + sign-dmg: "false" + apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} + apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + + - name: Build macOS DMG + if: ${{ github.event_name == 'push' || inputs.sign-dmg }} + shell: bash + run: | + set -euo pipefail + + target="${{ matrix.target }}" + release_dir="target/${target}/release" + dmg_root="${RUNNER_TEMP}/codex-dmg-root" + volname="Codex (${target})" + dmg_path="${release_dir}/codex-${target}.dmg" + + rm -rf "$dmg_root" + mkdir -p "$dmg_root" + + cp "${release_dir}/codex" "${dmg_root}/codex" + cp "${release_dir}/codex-responses-api-proxy" "${dmg_root}/codex-responses-api-proxy" + + rm -f "$dmg_path" + hdiutil create \ + -volname "$volname" \ + -srcfolder "$dmg_root" \ + -format UDZO \ + -ov \ + "$dmg_path" + + - name: Sign and notarize macOS DMG + if: ${{ github.event_name == 'push' || inputs.sign-dmg }} + uses: ./.github/actions/macos-code-sign + with: + target: ${{ matrix.target }} + sign-binaries: "false" + sign-dmg: "true" + apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} + apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + + - name: Validate signed artifacts + shell: bash + run: | + set -euo pipefail + + target="${{ matrix.target }}" + release_dir="target/${target}/release" + + codesign --verify --strict --verbose=2 "${release_dir}/codex" + codesign --verify --strict --verbose=2 "${release_dir}/codex-responses-api-proxy" + + dmg_path="${release_dir}/codex-${target}.dmg" + if [[ -f "$dmg_path" ]]; then + xcrun stapler validate "$dmg_path" + fi + + - name: Stage artifacts + shell: bash + run: | + set -euo pipefail + + target="${{ matrix.target }}" + release_dir="target/${target}/release" + dest="dist/${target}" + mkdir -p "$dest" + + cp "${release_dir}/codex" "$dest/codex-${target}" + cp "${release_dir}/codex-responses-api-proxy" "$dest/codex-responses-api-proxy-${target}" + + dmg_path="${release_dir}/codex-${target}.dmg" + if [[ -f "$dmg_path" ]]; then + cp "$dmg_path" "$dest/codex-${target}.dmg" + fi + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: one-off-mac-notarization-${{ matrix.target }} + path: codex-rs/dist/${{ matrix.target }}/* diff --git a/tools/distribution/.gitignore b/tools/distribution/.gitignore new file mode 100644 index 0000000000..4bd922a161 --- /dev/null +++ b/tools/distribution/.gitignore @@ -0,0 +1 @@ +secrets/ diff --git a/tools/distribution/cmd_import_api_key.py b/tools/distribution/cmd_import_api_key.py new file mode 100644 index 0000000000..5bb9dadb93 --- /dev/null +++ b/tools/distribution/cmd_import_api_key.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import argparse + +from rotation_shared import ( + DEFAULT_API_KEY_METADATA_JSON_FILENAME, + DEFAULT_ONE_PASSWORD_ACCOUNT, + DEFAULT_ONE_PASSWORD_API_KEY_TITLE_BASENAME, + cmd_import_api_key, +) + + +def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + parser = subparsers.add_parser( + "import-api-key", + help="Import a manually created App Store Connect API key for mac notarization", + ) + parser.add_argument( + "--private-key-path", required=True, help="Path to the downloaded AuthKey_.p8 file" + ) + parser.add_argument( + "--issuer-id", required=True, help="App Store Connect issuer ID for the imported key" + ) + parser.add_argument( + "--key-id", required=True, help="App Store Connect key ID for the imported key" + ) + parser.add_argument( + "--role", + choices=["developer", "app-manager"], + help="Optional role metadata to record alongside the imported key", + ) + parser.add_argument( + "--one-password-vault", help="Optional 1Password vault to store the API key item in" + ) + parser.add_argument( + "--one-password-title", + help=( + "Optional 1Password item title to create or update. Defaults to " + f"'{DEFAULT_ONE_PASSWORD_API_KEY_TITLE_BASENAME} ' when --one-password-vault is set" + ), + ) + parser.add_argument( + "--metadata-json-filename", + default=DEFAULT_API_KEY_METADATA_JSON_FILENAME, + help="Filename for the imported API key metadata", + ) + parser.add_argument( + "--one-password-account", + default=DEFAULT_ONE_PASSWORD_ACCOUNT, + help="1Password account to use for item creation", + ) + parser.set_defaults(func=cmd_import_api_key) diff --git a/tools/distribution/cmd_upload_api_key_secrets.py b/tools/distribution/cmd_upload_api_key_secrets.py new file mode 100644 index 0000000000..ae714cbcf8 --- /dev/null +++ b/tools/distribution/cmd_upload_api_key_secrets.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import argparse + +from rotation_shared import ( + DEFAULT_GITHUB_REPO, + DEFAULT_ISSUER_ID_SECRET_NAME, + DEFAULT_KEY_ID_SECRET_NAME, + DEFAULT_ONE_PASSWORD_ACCOUNT, + DEFAULT_ONE_PASSWORD_PASSWORD_FIELD, + DEFAULT_PRIVATE_KEY_SECRET_NAME, + cmd_upload_api_key_secrets, +) + + +def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + parser = subparsers.add_parser( + "upload-api-key-secrets", + help="Upload the mac notarization App Store Connect API key material to GitHub Actions secrets", + ) + parser.add_argument("--github-repo", default=DEFAULT_GITHUB_REPO) + parser.add_argument( + "--one-password-item", + required=True, + help="1Password item ID or title for the notarization API key", + ) + parser.add_argument( + "--one-password-vault", + required=True, + help="1Password vault containing the notarization API key item", + ) + parser.add_argument( + "--one-password-account", + default=DEFAULT_ONE_PASSWORD_ACCOUNT, + help="1Password account to use when loading the API key item", + ) + parser.add_argument( + "--private-key-field", + default=DEFAULT_ONE_PASSWORD_PASSWORD_FIELD, + help="1Password field containing the base64 private key", + ) + parser.add_argument( + "--issuer-id-field", default="issuer_id", help="1Password field containing the issuer ID" + ) + parser.add_argument( + "--key-id-field", default="key_id", help="1Password field containing the key ID" + ) + parser.add_argument("--issuer-id-secret-name", default=DEFAULT_ISSUER_ID_SECRET_NAME) + parser.add_argument("--key-id-secret-name", default=DEFAULT_KEY_ID_SECRET_NAME) + parser.add_argument("--private-key-secret-name", default=DEFAULT_PRIVATE_KEY_SECRET_NAME) + parser.set_defaults(func=cmd_upload_api_key_secrets) diff --git a/tools/distribution/rotate_signing_assets.py b/tools/distribution/rotate_signing_assets.py new file mode 100755 index 0000000000..1dfd6146db --- /dev/null +++ b/tools/distribution/rotate_signing_assets.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Notarization API key rotation helper for Codex CLI GitHub Actions. + +Dry-run is the default. Pass `--execute` (alias: `-x`) to run mutating commands. + +This intentionally keeps only the API-key path from the mac signing rotation +helper: + - import a manually-created App Store Connect API key, + - optionally store it in 1Password, + - upload the matching GitHub Actions repository secrets. +""" + +from __future__ import annotations + +import argparse +import functools +from typing import Sequence + +from cmd_import_api_key import register as register_import_api_key +from cmd_upload_api_key_secrets import register as register_upload_api_key_secrets +from rotation_shared import DEFAULT_OUT_DIR + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=__doc__, + allow_abbrev=False, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "-x", + "--execute", + action="store_true", + help="Run mutating commands (default is dry-run)", + ) + parser.add_argument( + "-v", + "--verbose", + dest="log_verbose", + action="store_true", + help="Print command execution/dry-run progress logs", + ) + parser.add_argument( + "-o", + "--out-dir", + default=str(DEFAULT_OUT_DIR), + help="Output directory for generated artifacts", + ) + + subparsers = parser.add_subparsers( + dest="subcommand", + required=True, + parser_class=functools.partial(argparse.ArgumentParser, allow_abbrev=False), + ) + + register_import_api_key(subparsers) + register_upload_api_key_secrets(subparsers) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + args.func(args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/distribution/rotation_shared.py b/tools/distribution/rotation_shared.py new file mode 100644 index 0000000000..f48bc91a4f --- /dev/null +++ b/tools/distribution/rotation_shared.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import argparse +import base64 +import csv +import io +import json +import os +import shlex +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Sequence +from zoneinfo import ZoneInfo + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_OUT_DIR = REPO_ROOT / "tools" / "distribution" / "secrets" +DEFAULT_API_KEY_METADATA_JSON_FILENAME = "app_store_connect_api_key.import.json" +DEFAULT_ONE_PASSWORD_ACCOUNT = "openai-team.1password.com" +DEFAULT_ONE_PASSWORD_PASSWORD_FIELD = "password" +DEFAULT_ONE_PASSWORD_API_KEY_TITLE_BASENAME = "Codex CLI Notarization API Key" +DEFAULT_GITHUB_REPO = "openai/codex" +DEFAULT_ISSUER_ID_SECRET_NAME = "APPLE_NOTARIZATION_ISSUER_ID" +DEFAULT_KEY_ID_SECRET_NAME = "APPLE_NOTARIZATION_KEY_ID" +DEFAULT_PRIVATE_KEY_SECRET_NAME = "APPLE_NOTARIZATION_KEY_P8" +PACIFIC_TZ = ZoneInfo("America/Los_Angeles") + + +class CommandRunner: + def __init__( + self, + *, + dry_run: bool, + env_overrides: dict[str, str] | None = None, + verbose: bool = False, + ) -> None: + self.dry_run = dry_run + self.verbose = verbose + self.env = os.environ.copy() + if env_overrides: + self.env.update({key: value for key, value in env_overrides.items() if value}) + + def run( + self, + argv: Sequence[str], + *, + stdin_text: str | None = None, + capture_json: bool = False, + cwd: Path | None = None, + redacted_argv: Sequence[str] | None = None, + ) -> Any: + pretty = " ".join(shlex.quote(part) for part in (redacted_argv or argv)) + prefix = "[dry-run]" if self.dry_run else "[exec]" + if self.verbose: + print(f"{prefix} {pretty}") + if stdin_text is not None and self.verbose: + print(f"{prefix} stdin: <{len(stdin_text)} bytes>") + if self.dry_run: + if capture_json: + return {} + return None + + try: + completed = subprocess.run( + list(argv), + cwd=str(cwd or REPO_ROOT), + env=self.env, + input=stdin_text, + text=True, + capture_output=capture_json, + check=True, + ) + except subprocess.CalledProcessError as error: + print( + f"[error] Command failed with exit code {error.returncode}: {pretty}", + file=sys.stderr, + ) + if getattr(error, "stdout", None): + print("[error] stdout:", file=sys.stderr) + print(error.stdout, file=sys.stderr) + if getattr(error, "stderr", None): + print("[error] stderr:", file=sys.stderr) + print(error.stderr, file=sys.stderr) + raise + if not capture_json: + return None + if not completed.stdout.strip(): + return {} + return json.loads(completed.stdout) + + +def ensure_parent(path: Path, *, dry_run: bool, verbose: bool = False) -> None: + if dry_run: + if verbose: + print(f"[dry-run] Would create directory: {path}") + return + path.mkdir(parents=True, exist_ok=True) + + +def repo_path(value: str) -> Path: + path = Path(value).expanduser() + if path.is_absolute(): + return path + return REPO_ROOT / path + + +def default_one_password_notarization_api_key_title() -> str: + return f"{DEFAULT_ONE_PASSWORD_API_KEY_TITLE_BASENAME} {datetime.now(PACIFIC_TZ).date().isoformat()}" + + +def normalize_base64_secret_body(body: str) -> str: + # GitHub secrets and local .base64 files should be stored as a single line. + return "".join(body.split()) + + +def normalize_private_key_secret_body(body: str) -> str: + if ("BEGIN " + "PRIVATE KEY") in body: + return normalize_base64_secret_body(base64.b64encode(body.encode("utf-8")).decode("ascii")) + return normalize_base64_secret_body(body) + + +def _escape_one_password_assignment_name(value: str) -> str: + return value.replace("\\", "\\\\").replace(".", "\\.").replace("=", "\\=") + + +def _parse_one_password_fields(raw: str, fields: Sequence[str]) -> dict[str, str]: + rows = list(csv.reader(io.StringIO(raw))) + if not rows or not rows[0]: + raise SystemExit("Failed to parse 1Password field output") + values = rows[0] + if len(values) < len(fields): + raise SystemExit(f"Expected {len(fields)} fields from 1Password, got {len(values)}") + return {field: values[index] for index, field in enumerate(fields)} + + +def load_one_password_item_fields( + *, + item: str, + fields: Sequence[str], + vault: str | None, + account: str, +) -> dict[str, str]: + argv = [ + "op", + "item", + "get", + item, + f"--fields={','.join(fields)}", + "--account", + account, + "--reveal", + ] + if vault: + argv.extend(["--vault", vault]) + try: + raw = subprocess.check_output(argv, text=True) + except Exception as error: + raise SystemExit(f"Failed to load 1Password fields from {item!r}: {error}") from error + return _parse_one_password_fields(raw, fields) + + +def _one_password_item_exists( + *, + item: str, + vault: str, + account: str, +) -> bool: + argv = [ + "op", + "item", + "get", + item, + "--vault", + vault, + "--account", + account, + "--format", + "json", + ] + completed = subprocess.run( + argv, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + return completed.returncode == 0 + + +def _one_password_assignment_for_file(path: Path) -> str: + return f"{_escape_one_password_assignment_name(path.name)}[file]={path.resolve(strict=False)}" + + +def _one_password_assignment(name: str, value: str, *, field_type: str = "text") -> str: + escaped_name = _escape_one_password_assignment_name(name) + if field_type: + return f"{escaped_name}[{field_type}]={value}" + return f"{escaped_name}={value}" + + +def create_one_password_notarization_api_key_item( + runner: CommandRunner, + *, + vault: str, + title: str, + account: str, + private_key_base64: str, + issuer_id: str, + key_id: str, + role: str | None, + created_date: str, + key_path: Path, +) -> None: + file_assignments = [_one_password_assignment_for_file(key_path)] + sensitive_assignment = f"password={private_key_base64}" + field_assignments = [ + _one_password_assignment("issuer_id", issuer_id), + _one_password_assignment("key_id", key_id), + _one_password_assignment("role", role or ""), + _one_password_assignment("created_date", created_date), + ] + redacted_field_assignments = list(field_assignments) + item_exists = ( + True + if runner.dry_run + else _one_password_item_exists(item=title, vault=vault, account=account) + ) + + if item_exists: + runner.run( + [ + "op", + "item", + "edit", + title, + "--account", + account, + "--vault", + vault, + sensitive_assignment, + *field_assignments, + *file_assignments, + ], + redacted_argv=[ + "op", + "item", + "edit", + title, + "--account", + account, + "--vault", + vault, + "password=", + *redacted_field_assignments, + *file_assignments, + ], + ) + return + + runner.run( + [ + "op", + "item", + "create", + "--account", + account, + "--category", + "password", + "--vault", + vault, + "--title", + title, + sensitive_assignment, + *field_assignments, + *file_assignments, + ], + redacted_argv=[ + "op", + "item", + "create", + "--account", + account, + "--category", + "password", + "--vault", + vault, + "--title", + title, + "password=", + *redacted_field_assignments, + *file_assignments, + ], + ) + + +def cmd_import_api_key(args: argparse.Namespace) -> None: + out_dir = repo_path(args.out_dir) + ensure_parent(out_dir, dry_run=not args.execute, verbose=args.log_verbose) + source_key_path = Path(args.private_key_path).expanduser() + key_path = out_dir / f"AuthKey_{args.key_id}.p8" + metadata_path = out_dir / args.metadata_json_filename + private_key = source_key_path.read_text(encoding="utf-8") + if not private_key.endswith("\n"): + private_key = f"{private_key}\n" + private_key_base64 = normalize_private_key_secret_body(private_key) + created_date = datetime.now(PACIFIC_TZ).date().isoformat() + runner = CommandRunner(dry_run=not args.execute, verbose=args.log_verbose) + + if args.execute: + key_path.write_text(private_key, encoding="utf-8") + os.chmod(key_path, 0o600) + metadata_payload = { + "issuer_id": args.issuer_id, + "key_id": args.key_id, + "role": args.role, + "source_private_key_path": str(source_key_path), + } + metadata_path.write_text( + json.dumps(metadata_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + elif args.log_verbose: + print(f"[dry-run] Would copy API key contents from {source_key_path} to: {key_path}") + print(f"[dry-run] Would write API key metadata to: {metadata_path}") + + one_password_title = args.one_password_title + if args.one_password_vault and not one_password_title: + one_password_title = default_one_password_notarization_api_key_title() + + if args.one_password_vault and one_password_title: + create_one_password_notarization_api_key_item( + runner, + vault=args.one_password_vault, + title=one_password_title, + account=args.one_password_account, + private_key_base64=private_key_base64, + issuer_id=args.issuer_id, + key_id=args.key_id, + role=args.role, + created_date=created_date, + key_path=key_path, + ) + + print(f"Imported App Store Connect API key ID: {args.key_id}") + print(f"Issuer ID: {args.issuer_id}") + if args.one_password_vault and one_password_title: + print(f"Saved 1Password item: {one_password_title}") + + +def cmd_upload_api_key_secrets(args: argparse.Namespace) -> None: + runner = CommandRunner(dry_run=not args.execute, verbose=args.log_verbose) + if not args.execute: + private_key_body = "" + issuer_id = "" + key_id = "" + else: + loaded_fields = load_one_password_item_fields( + item=args.one_password_item, + fields=[args.private_key_field, args.issuer_id_field, args.key_id_field], + vault=args.one_password_vault, + account=args.one_password_account, + ) + private_key_body = normalize_private_key_secret_body(loaded_fields[args.private_key_field]) + issuer_id = loaded_fields[args.issuer_id_field].strip() + key_id = loaded_fields[args.key_id_field].strip() + if not private_key_body or not issuer_id or not key_id: + raise SystemExit( + "The 1Password notarization API key item is missing one or more required fields. " + "Expected a base64 private key in the password field plus issuer_id and key_id custom fields." + ) + + runner.run( + [ + "gh", + "secret", + "set", + "--repo", + args.github_repo, + args.issuer_id_secret_name, + ], + stdin_text=issuer_id, + ) + runner.run( + [ + "gh", + "secret", + "set", + "--repo", + args.github_repo, + args.key_id_secret_name, + ], + stdin_text=key_id, + ) + runner.run( + [ + "gh", + "secret", + "set", + "--repo", + args.github_repo, + args.private_key_secret_name, + ], + stdin_text=private_key_body, + )