Compare commits

...

4 Commits

Author SHA1 Message Date
Eric Burke
48477b5b6f Temporarily validate empty-password Developer ID certificate 2026-04-08 15:11:33 -07:00
Eric Burke
f7bed6cc95 Restore notarization secret names 2026-04-06 11:51:40 -07:00
Eric Burke
1c8fd8e52e Use temporary notarization secrets for one-off workflow 2026-04-06 11:29:56 -07:00
Eric Burke
25244c84fc Add notarization API key rotation helper 2026-04-06 11:19:11 -07:00
8 changed files with 943 additions and 5 deletions

View File

@@ -44,11 +44,6 @@ runs:
exit 1
fi
if [[ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]]; then
echo "APPLE_CERTIFICATE_PASSWORD is required for macOS signing"
exit 1
fi
cert_path="${RUNNER_TEMP}/apple_signing_certificate.p12"
echo "$APPLE_CERTIFICATE" | base64 -d > "$cert_path"

View File

@@ -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.NEW_APPLE_CERTIFICATE_P12 }}
apple-certificate-password: ${{ secrets.NEW_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.NEW_APPLE_CERTIFICATE_P12 }}
apple-certificate-password: ${{ secrets.NEW_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
tools/distribution/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
secrets/

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)

View File

@@ -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)

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
import argparse
from rotation_shared import (
DEFAULT_GITHUB_REPO,
DEFAULT_ONE_PASSWORD_ACCOUNT,
NEW_MAC_SIGNING_CERTIFICATE_PASSWORD_SECRET_NAME,
NEW_MAC_SIGNING_CERTIFICATE_SECRET_NAME,
cmd_upload_mac_signing_secrets,
)
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
parser = subparsers.add_parser(
"upload-mac-signing-secrets",
help="Upload a Developer ID Application p12 from 1Password to temporary 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 that has one attached .p12 file",
)
parser.add_argument(
"--one-password-vault",
required=True,
help="1Password vault containing the mac signing item",
)
parser.add_argument(
"--one-password-account",
default=DEFAULT_ONE_PASSWORD_ACCOUNT,
help="1Password account to use when loading the item",
)
parser.add_argument(
"--p12-file-name",
help="Attached .p12 filename to download; required only if the item has multiple .p12 files",
)
parser.add_argument("--certificate-secret-name", default=NEW_MAC_SIGNING_CERTIFICATE_SECRET_NAME)
parser.add_argument(
"--password-secret-name", default=NEW_MAC_SIGNING_CERTIFICATE_PASSWORD_SECRET_NAME
)
parser.add_argument(
"--certificate-password",
default="",
help="Password for the p12. Defaults to the empty password used by the temporary certificate.",
)
parser.set_defaults(func=cmd_upload_mac_signing_secrets)

View File

@@ -0,0 +1,73 @@
#!/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.
- upload an existing Developer ID Application p12 for temporary testing.
"""
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 cmd_upload_mac_signing_secrets import register as register_upload_mac_signing_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)
register_upload_mac_signing_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())

View File

@@ -0,0 +1,556 @@
from __future__ import annotations
import argparse
import base64
import csv
import io
import json
import os
import shlex
import subprocess
import sys
import tempfile
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"
NEW_MAC_SIGNING_CERTIFICATE_SECRET_NAME = "NEW_APPLE_CERTIFICATE_P12"
NEW_MAC_SIGNING_CERTIFICATE_PASSWORD_SECRET_NAME = "NEW_APPLE_CERTIFICATE_PASSWORD"
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 load_one_password_item_json(
*,
item: str,
vault: str | None,
account: str,
) -> dict[str, Any]:
argv = [
"op",
"item",
"get",
item,
"--account",
account,
"--format",
"json",
]
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 item {item!r}: {error}") from error
try:
payload = json.loads(raw)
except json.JSONDecodeError as error:
raise SystemExit(f"Failed to parse 1Password item JSON for {item!r}: {error}") from error
if not isinstance(payload, dict):
raise SystemExit(f"1Password item JSON for {item!r} was not an object")
return payload
def _one_password_item_file_name(file_payload: object) -> str | None:
if not isinstance(file_payload, dict):
return None
for key in ("name", "fileName", "title"):
value = file_payload.get(key)
if isinstance(value, str) and value.strip():
return Path(value).name
return None
def one_password_item_file_names(
*,
item: str,
vault: str | None,
account: str,
) -> list[str]:
payload = load_one_password_item_json(item=item, vault=vault, account=account)
raw_files = payload.get("files", [])
if not isinstance(raw_files, list):
return []
return [
file_name
for file_name in (_one_password_item_file_name(file_payload) for file_payload in raw_files)
if file_name
]
def select_one_password_p12_file_name(file_names: Sequence[str]) -> str:
p12_file_names = sorted(
{file_name for file_name in file_names if file_name.lower().endswith(".p12")}
)
if not p12_file_names:
raise SystemExit("The 1Password item does not have an attached .p12 file")
if len(p12_file_names) > 1:
raise SystemExit(
"The 1Password item has multiple .p12 files; pass --p12-file-name with one of: "
+ ", ".join(p12_file_names)
)
return p12_file_names[0]
def one_password_secret_reference(*, vault: str, item: str, field_or_file: str) -> str:
return f"op://{vault}/{item}/{field_or_file}"
def download_one_password_file(
*,
item: str,
vault: str,
account: str,
file_name: str,
output_path: Path,
) -> None:
argv = [
"op",
"read",
"--out-file",
str(output_path),
"--file-mode",
"0600",
one_password_secret_reference(vault=vault, item=item, field_or_file=file_name),
"--account",
account,
]
try:
subprocess.run(argv, check=True, stdout=subprocess.DEVNULL)
except subprocess.CalledProcessError as error:
raise SystemExit(
f"Failed to download 1Password file {file_name!r} from {item!r}: {error}"
) from error
if not output_path.exists():
raise SystemExit(f"1Password file download did not produce {output_path}")
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,
)
def cmd_upload_mac_signing_secrets(args: argparse.Namespace) -> None:
runner = CommandRunner(dry_run=not args.execute, verbose=args.log_verbose)
file_name = args.p12_file_name or select_one_password_p12_file_name(
one_password_item_file_names(
item=args.one_password_item,
vault=args.one_password_vault,
account=args.one_password_account,
)
)
with tempfile.TemporaryDirectory(prefix="codex-cli-mac-signing-p12-") as temp_dir:
p12_path = Path(temp_dir) / file_name
download_one_password_file(
item=args.one_password_item,
vault=args.one_password_vault,
account=args.one_password_account,
file_name=file_name,
output_path=p12_path,
)
p12_body = normalize_base64_secret_body(
base64.b64encode(p12_path.read_bytes()).decode("ascii")
)
runner.run(
[
"gh",
"secret",
"set",
"--repo",
args.github_repo,
args.certificate_secret_name,
],
stdin_text=p12_body,
)
runner.run(
[
"gh",
"secret",
"set",
"--repo",
args.github_repo,
args.password_secret_name,
"--body",
args.certificate_password,
],
)