mirror of
https://github.com/openai/codex.git
synced 2026-05-04 21:32:21 +03:00
Compare commits
4 Commits
automation
...
release/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48477b5b6f | ||
|
|
f7bed6cc95 | ||
|
|
1c8fd8e52e | ||
|
|
25244c84fc |
5
.github/actions/macos-code-sign/action.yml
vendored
5
.github/actions/macos-code-sign/action.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
162
.github/workflows/one-off-mac-notarization.yaml
vendored
Normal file
162
.github/workflows/one-off-mac-notarization.yaml
vendored
Normal 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
1
tools/distribution/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
secrets/
|
||||
52
tools/distribution/cmd_import_api_key.py
Normal file
52
tools/distribution/cmd_import_api_key.py
Normal 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)
|
||||
51
tools/distribution/cmd_upload_api_key_secrets.py
Normal file
51
tools/distribution/cmd_upload_api_key_secrets.py
Normal 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)
|
||||
48
tools/distribution/cmd_upload_mac_signing_secrets.py
Normal file
48
tools/distribution/cmd_upload_mac_signing_secrets.py
Normal 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)
|
||||
73
tools/distribution/rotate_signing_assets.py
Executable file
73
tools/distribution/rotate_signing_assets.py
Executable 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())
|
||||
556
tools/distribution/rotation_shared.py
Normal file
556
tools/distribution/rotation_shared.py
Normal 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,
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user