Add notarization API key rotation helper

This commit is contained in:
Eric Burke
2026-04-06 11:19:11 -07:00
parent 4ce97cef02
commit 25244c84fc
6 changed files with 736 additions and 0 deletions
@@ -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 <your branch>] [-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 }}/*
+1
View File
@@ -0,0 +1 @@
secrets/
+52
View File
@@ -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_<KEY_ID>.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} <YYYY-MM-DD>' 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)
@@ -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)
+70
View File
@@ -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())
+400
View File
@@ -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=<private-key-base64>",
*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=<private-key-base64>",
*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 = "<loaded-from-1password>"
issuer_id = "<loaded-from-1password>"
key_id = "<loaded-from-1password>"
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,
)