fix: improve npm release process (#2055)

This improves the release process by introducing
`scripts/publish_to_npm.py` to automate publishing to npm (modulo the
human 2fac step).

As part of this, it updates `.github/workflows/rust-release.yml` to
create the artifact for npm using `npm pack`.

And finally, while it is long overdue, this memorializes the release
process in `docs/release_management.md`.
This commit is contained in:
Michael Bolin
2025-08-08 19:07:36 -07:00
committed by GitHub
parent 329f01b728
commit e87974ae83
3 changed files with 170 additions and 1 deletions

118
scripts/publish_to_npm.py Executable file
View File

@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""
Download a release artifact for the npm package and publish it.
Given a release version like `0.20.0`, this script:
- Downloads the `codex-npm-<version>.tgz` asset from the GitHub release
tagged `rust-v<version>` in the `openai/codex` repository using `gh`.
- Runs `npm publish` on the downloaded tarball to publish `@openai/codex`.
Flags:
- `--dry-run` delegates to `npm publish --dry-run`. The artifact is still
downloaded so npm can inspect the archive contents without publishing.
Requirements:
- GitHub CLI (`gh`) must be installed and authenticated to access the repo.
- npm must be logged in with an account authorized to publish
`@openai/codex`. This may trigger a browser for 2FA.
"""
import argparse
import os
import subprocess
import sys
import tempfile
from pathlib import Path
def run_checked(cmd: list[str], cwd: Path | None = None) -> None:
"""Run a subprocess command and raise if it fails."""
proc = subprocess.run(cmd, cwd=str(cwd) if cwd else None)
proc.check_returncode()
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Download the npm release artifact for a given version and publish it."
)
)
parser.add_argument(
"version",
help="Release version to publish, e.g. 0.20.0 (without the 'v' prefix)",
)
parser.add_argument(
"--dir",
type=Path,
help=(
"Optional directory to download the artifact into. Defaults to a temporary directory."
),
)
parser.add_argument(
"-n",
"--dry-run",
action="store_true",
help="Delegate to `npm publish --dry-run` (still downloads the artifact).",
)
args = parser.parse_args()
version: str = args.version.lstrip("v")
tag = f"rust-v{version}"
asset_name = f"codex-npm-{version}.tgz"
download_dir_context_manager = (
tempfile.TemporaryDirectory() if args.dir is None else None
)
# Use provided dir if set, else the temporary one created above
download_dir: Path = args.dir if args.dir else Path(download_dir_context_manager.name) # type: ignore[arg-type]
download_dir.mkdir(parents=True, exist_ok=True)
# 1) Download the artifact using gh
repo = "openai/codex"
gh_cmd = [
"gh",
"release",
"download",
tag,
"--repo",
repo,
"--pattern",
asset_name,
"--dir",
str(download_dir),
]
print(f"Downloading {asset_name} from {repo}@{tag} into {download_dir}...")
# Even in --dry-run we download so npm can inspect the tarball.
run_checked(gh_cmd)
artifact_path = download_dir / asset_name
if not args.dry_run and not artifact_path.is_file():
print(
f"Error: expected artifact not found after download: {artifact_path}",
file=sys.stderr,
)
return 1
# 2) Publish to npm
npm_cmd = ["npm", "publish"]
if args.dry_run:
npm_cmd.append("--dry-run")
npm_cmd.append(str(artifact_path))
# Ensure CI is unset so npm can open a browser for 2FA if needed.
env = os.environ.copy()
if env.get("CI"):
env.pop("CI")
print("Running:", " ".join(npm_cmd))
proc = subprocess.run(npm_cmd, env=env)
proc.check_returncode()
print("Publish complete.")
# Keep the temporary directory alive until here; it is cleaned up on exit
return 0
if __name__ == "__main__":
sys.exit(main())