From e87974ae833358272bf995950a302f6a214e0100 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 8 Aug 2025 19:07:36 -0700 Subject: [PATCH] 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`. --- .github/workflows/rust-release.yml | 6 +- docs/release_management.md | 47 ++++++++++++ scripts/publish_to_npm.py | 118 +++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 docs/release_management.md create mode 100755 scripts/publish_to_npm.py diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 8e5aef27..8bf7361d 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -182,7 +182,11 @@ jobs: --release-version "${{ steps.release_name.outputs.name }}" \ --tmp "${TMP_DIR}" mkdir -p dist/npm - (cd "$TMP_DIR" && zip -r "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.zip" .) + # Produce an npm-ready tarball using `npm pack` and store it in dist/npm. + # We then rename it to a stable name used by our publishing script. + (cd "$TMP_DIR" && npm pack --pack-destination "${GITHUB_WORKSPACE}/dist/npm") + mv "${GITHUB_WORKSPACE}"/dist/npm/*.tgz \ + "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz" - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/docs/release_management.md b/docs/release_management.md new file mode 100644 index 00000000..1b81bc3e --- /dev/null +++ b/docs/release_management.md @@ -0,0 +1,47 @@ +# Release Management + +Currently, we made Codex binaries available in three places: + +- GitHub Releases https://github.com/openai/codex/releases/ +- `@openai/codex` on npm: https://www.npmjs.com/package/@openai/codex +- `codex` on Homebrew: https://formulae.brew.sh/formula/codex + +# Cutting a Release + +Currently, choosing the version number for the next release is a manual process. In general, just go to https://github.com/openai/codex/releases/latest and see what the latest release is and increase the minor version by `1`, so if the current release is `0.20.0`, then the next release should be `0.21.0`. + +Assuming you are trying to publish `0.21.0`, first you would run: + +```shell +VERSION=0.21.0 +./codex-rs/scripts/create_github_release.sh "$VERSION" +``` + +This will kick off a GitHub Action to build the release, so go to https://github.com/openai/codex/actions/workflows/rust-release.yml to find the corresponding workflow. (Note: we should automate finding the workflow URL with `gh`.) + +When the workflow finishes, the GitHub Release is "done," but you still have to consider npm and Homebrew. + +## Publishing to npm + +After the GitHub Release is done, you can publish to npm. Note the GitHub Release includes the appropriate artifact for npm (which is the output of `npm pack`), which should be named `codex-npm-VERSION.tgz`. To publish to npm, run: + +``` +VERSION=0.21.0 +./scripts/publish_to_npm.py "$VERSION" +``` + +Note that you must have permissions to publish to https://www.npmjs.com/package/@openai/codex for this to succeed. + +## Publishing to Homebrew + +For Homebrew, we are properly set up with their automation system, so every few hours or so it will check our GitHub repo to see if there is a new release. When it finds one, it will put up a PR to create the equivalent Homebrew release, which entails building Codex CLI from source on various versions of macOS. + +Inevitably, you just have to refresh this page periodically to see if the release has been picked up by their automation system: + +https://github.com/Homebrew/homebrew-core/pulls?q=%3Apr+codex + +Once everything builds, a Homebrew admin has to approve the PR. Again, the whole process takes several hours and we don't have total control over it, but it seems to work pretty well. + +For reference, our Homebrew formula lives at: + +https://github.com/Homebrew/homebrew-core/blob/main/Formula/c/codex.rb diff --git a/scripts/publish_to_npm.py b/scripts/publish_to_npm.py new file mode 100755 index 00000000..f79843ff --- /dev/null +++ b/scripts/publish_to_npm.py @@ -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-.tgz` asset from the GitHub release + tagged `rust-v` 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())