From 66af2178656f8b385e1c61ec84148576ce955a81 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Thu, 14 May 2026 17:25:32 -0700 Subject: [PATCH 1/2] Fix /review mode MCP startup render issue (#21624) This change fixes the case where the UI can sit on _"Starting MCP servers"_ even though the review work is already running or has already completed. - MCP startup status header is visible when a `/review` turn starts with enabled MCP server startups - Restore the underlying _Working..._ status after MCP startup completes or fails - Add regression coverage for overlapping startup/turn flows and status restoration _De-scoped from a broader thread-scoped MCP status change that would have made it easier to route MCP startup statuses to the appropriate thread (parent vs. review). These changes address the UI regression without requiring more significant changes across app-server & core._ Fixes #18792. --- codex-rs/tui/src/chatwidget/mcp_startup.rs | 23 +++- .../tui/src/chatwidget/tests/mcp_startup.rs | 116 ++++++++++++++++++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 4 +- 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/mcp_startup.rs b/codex-rs/tui/src/chatwidget/mcp_startup.rs index cf499a455b..05d3def329 100644 --- a/codex-rs/tui/src/chatwidget/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/mcp_startup.rs @@ -11,6 +11,9 @@ use codex_app_server_protocol::McpServerStatusUpdatedNotification; use super::ChatWidget; +const MCP_STARTUP_SINGLE_HEADER_PREFIX: &str = "Booting MCP server:"; +const MCP_STARTUP_MULTI_HEADER_PREFIX: &str = "Starting MCP servers"; + #[derive(Debug, Clone)] pub(crate) enum McpStartupStatus { Starting, @@ -153,11 +156,11 @@ impl ChatWidget { } let header = if total > 1 { format!( - "Starting MCP servers ({completed}/{total}): {}", + "{MCP_STARTUP_MULTI_HEADER_PREFIX} ({completed}/{total}): {}", to_show.join(", ") ) } else { - format!("Booting MCP server: {first}") + format!("{MCP_STARTUP_SINGLE_HEADER_PREFIX} {first}") }; self.set_status_header(header); } @@ -187,12 +190,16 @@ impl ChatWidget { self.on_warning(format!("MCP startup incomplete ({})", parts.join("; "))); } + let mcp_startup_owned_status = self.status_header_is_mcp_startup_owned(); self.mcp_startup_status = None; self.mcp_startup_ignore_updates_until_next_start = true; self.mcp_startup_allow_terminal_only_next_round = false; self.mcp_startup_pending_next_round.clear(); self.mcp_startup_pending_next_round_saw_starting = false; self.update_task_running_state(); + if self.bottom_pane.is_task_running() && mcp_startup_owned_status { + self.restore_reasoning_status_header(); + } self.maybe_send_next_queued_input(); self.request_redraw(); } @@ -234,6 +241,18 @@ impl ChatWidget { self.finish_mcp_startup(failed, cancelled); } + pub(super) fn status_header_is_mcp_startup_owned(&self) -> bool { + self.status_state + .current_status + .header + .starts_with(MCP_STARTUP_SINGLE_HEADER_PREFIX) + || self + .status_state + .current_status + .header + .starts_with(MCP_STARTUP_MULTI_HEADER_PREFIX) + } + pub(super) fn on_mcp_server_status_updated( &mut self, notification: McpServerStatusUpdatedNotification, diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index da4777bfb1..ba862a5293 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -57,6 +57,46 @@ async fn mcp_startup_complete_does_not_clear_running_task() { assert!(chat.bottom_pane.is_task_running()); assert!(chat.bottom_pane.status_indicator_visible()); + assert_eq!(chat.status_state.current_status.header, "Working"); +} + +#[tokio::test] +async fn turn_start_preserves_active_mcp_startup_header() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_mcp_startup_expected_servers(["schaltwerk".to_string()]); + + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Starting); + handle_turn_started(&mut chat, "turn-1"); + + assert!(chat.bottom_pane.is_task_running()); + assert_eq!( + chat.status_state.current_status.header, + "Booting MCP server: schaltwerk" + ); + + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Ready); + + assert_eq!(chat.status_state.current_status.header, "Working"); +} + +#[tokio::test] +async fn turn_start_replaces_idle_completed_mcp_startup_header() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_mcp_startup_expected_servers(["schaltwerk".to_string()]); + + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Starting); + notify_mcp_status(&mut chat, "schaltwerk", McpServerStartupState::Ready); + + assert!(!chat.bottom_pane.is_task_running()); + assert_eq!( + chat.status_state.current_status.header, + "Booting MCP server: schaltwerk" + ); + + handle_turn_started(&mut chat, "turn-1"); + + assert!(chat.bottom_pane.is_task_running()); + assert_eq!(chat.status_state.current_status.header, "Working"); } #[tokio::test] @@ -125,6 +165,82 @@ async fn app_server_mcp_startup_failure_renders_warning_history() { ); } +#[tokio::test] +async fn mcp_startup_failure_restores_running_status_header() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string(), "beta".to_string()]); + handle_turn_started(&mut chat, "turn-1"); + + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Starting); + assert!( + chat.status_state + .current_status + .header + .starts_with("Starting MCP servers") + ); + + notify_mcp_status_error( + &mut chat, + "alpha", + "MCP client for `alpha` failed to start: handshake failed", + ); + notify_mcp_status(&mut chat, "beta", McpServerStartupState::Ready); + let _ = drain_insert_history(&mut rx); + + assert!(chat.bottom_pane.is_task_running()); + assert!(chat.bottom_pane.status_indicator_visible()); + assert_eq!(chat.status_state.current_status.header, "Working"); +} + +#[tokio::test] +async fn mcp_startup_complete_preserves_review_status() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.show_welcome_banner = false; + chat.set_mcp_startup_expected_servers(["alpha".to_string()]); + handle_turn_started(&mut chat, "turn-1"); + + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Starting); + assert!( + chat.status_state + .current_status + .header + .starts_with("Booting MCP server") + ); + + chat.on_guardian_assessment(GuardianAssessmentEvent { + id: "guardian-1".to_string(), + target_item_id: Some("guardian-target-1".to_string()), + turn_id: "turn-1".to_string(), + started_at_ms: 0, + completed_at_ms: None, + status: GuardianAssessmentStatus::InProgress, + risk_level: None, + user_authorization: None, + rationale: None, + decision_source: None, + action: GuardianAssessmentAction::Command { + source: GuardianCommandSource::Shell, + command: "rm -rf '/tmp/guardian target'".to_string(), + cwd: test_path_buf("/tmp").abs(), + }, + }); + + notify_mcp_status(&mut chat, "alpha", McpServerStartupState::Ready); + + assert!(chat.bottom_pane.is_task_running()); + assert!(chat.bottom_pane.status_indicator_visible()); + assert_eq!( + chat.status_state.current_status.header, + "Reviewing approval request" + ); + assert_eq!( + chat.status_state.current_status.details, + Some("rm -rf '/tmp/guardian target'".to_string()) + ); +} + #[tokio::test] async fn app_server_mcp_startup_lag_settles_startup_and_ignores_late_updates() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index 077c33606d..d82eca338f 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -66,7 +66,9 @@ impl ChatWidget { self.bottom_pane .set_interrupt_hint_visible(/*visible*/ true); self.status_state.terminal_title_status_kind = TerminalTitleStatusKind::Working; - self.set_status_header(String::from("Working")); + if self.mcp_startup_status.is_none() || !self.status_header_is_mcp_startup_owned() { + self.set_status_header(String::from("Working")); + } self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); self.set_ambient_pet_notification( From fcc3a3f6caec8886b388ffc0bfa56d60d242ca3f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 14 May 2026 17:48:52 -0700 Subject: [PATCH 2/2] ci: support signed macOS release promotion ## Why `rust-release.yml` can create unsigned macOS artifacts for external signing, but there was no signed resume path after those artifacts returned from a secure enclave. Release operators need a way to reuse the first run artifacts, ingest signed macOS binaries and DMGs, and continue the normal signed release path without rebuilding every platform or treating handoff assets as final release assets. ## What Changed - Add explicit manual `release_mode` values for `build_unsigned` and `promote_signed` while keeping `sign_macos` as a deprecated compatibility input. - Add promote inputs for `unsigned_run_id`, `signed_macos_asset`, and optional `signed_macos_sha256`. - Add a `stage-signed-macos` job that downloads the signed handoff asset from the GitHub Release, verifies signed binaries and stapled DMGs, repacks normal macOS release artifacts, and builds macOS Python runtime wheels. - Teach the release job to download Part 1 artifacts from the unsigned run, discard unsigned macOS staging artifacts, re-upload promoted Linux and Windows artifacts for npm staging, and then run the signed release tail. - Clean up unsigned and signed handoff release assets after successful promotion. ## Verification - Parsed `.github/workflows/rust-release.yml` with Ruby YAML loading. No developers.openai.com documentation update is needed. --- .github/workflows/rust-release.yml | 421 ++++++++++++++++++++++++++++- 1 file changed, 413 insertions(+), 8 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index ca082812c6..812547ee39 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,6 +4,13 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` +# +# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, +# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff +# archive as a GitHub Release asset, then manually dispatch +# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. +# The signed handoff archive should contain target or artifact directories such +# as `aarch64-apple-darwin/` with signed binaries and signed DMGs. name: rust-release on: @@ -12,11 +19,31 @@ on: - "rust-v*.*.*" workflow_dispatch: inputs: + release_mode: + description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + required: false + type: choice + default: build_unsigned + options: + - build_unsigned + - promote_signed sign_macos: - description: "Sign and notarize macOS release artifacts." + description: "Deprecated compatibility input; use release_mode instead." required: false type: boolean - default: true + default: false + unsigned_run_id: + description: "For promote_signed: workflow run id from the build_unsigned run." + required: false + type: string + signed_macos_asset: + description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." + required: false + type: string + signed_macos_sha256: + description: "For promote_signed: optional SHA-256 of signed_macos_asset." + required: false + type: string concurrency: group: ${{ github.workflow }} @@ -33,14 +60,57 @@ jobs: - name: Validate tag matches Cargo.toml version shell: bash env: - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} + RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} + REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${SIGN_MACOS}" == "true" ]]; then - echo "❌ Manual rust-release runs must set sign_macos=false" - exit 1 + case "${RELEASE_MODE}" in + signed) + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" + exit 1 + fi + ;; + build_unsigned) + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + echo "❌ release_mode=build_unsigned is only valid for manual runs" + exit 1 + fi + ;; + promote_signed) + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + echo "❌ release_mode=promote_signed is only valid for manual runs" + exit 1 + fi + if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then + echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" + exit 1 + fi + if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then + echo "❌ release_mode=promote_signed requires signed_macos_asset" + exit 1 + fi + if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then + echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" + exit 1 + fi + if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then + echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" + exit 1 + fi + ;; + *) + echo "❌ Unknown release_mode '${RELEASE_MODE}'" + exit 1 + ;; + esac + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then + echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." fi # 1. Must be a tag and match the regex @@ -62,6 +132,7 @@ jobs: echo "::endgroup::" build: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on || matrix.runner }} @@ -78,7 +149,7 @@ jobs: # 2026-03-04: temporarily change releases to use thin LTO because # Ubuntu ARM is timing out at 60 minutes. CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} + SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} strategy: fail-fast: false @@ -553,7 +624,230 @@ jobs: path: | codex-rs/dist/${{ matrix.target }}/* + stage-signed-macos: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} + needs: tag-check + name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} + runs-on: macos-15-xlarge + timeout-minutes: 30 + permissions: + contents: read + defaults: + run: + working-directory: codex-rs + + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + bundle: primary + artifact_name: aarch64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - target: aarch64-apple-darwin + bundle: app-server + artifact_name: aarch64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + - target: x86_64-apple-darwin + bundle: primary + artifact_name: x86_64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - target: x86_64-apple-darwin + bundle: app-server + artifact_name: x86_64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download signed macOS handoff + shell: bash + env: + GH_TOKEN: ${{ github.token }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} + run: | + set -euo pipefail + + download_dir="${RUNNER_TEMP}/signed-macos-download" + handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" + rm -rf "$download_dir" "$handoff_dir" + mkdir -p "$download_dir" "$handoff_dir" + + gh release download "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "$SIGNED_MACOS_ASSET" \ + --dir "$download_dir" + + asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" + if [[ "$asset_count" != "1" ]]; then + echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" + find "$download_dir" -maxdepth 1 -type f -print + exit 1 + fi + + asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" + if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then + expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" + actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" + if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" + echo "expected: ${expected_sha}" + echo "actual: ${actual_sha}" + exit 1 + fi + fi + + asset_name="$(basename "$asset_path")" + case "$asset_name" in + *.tar.zst) + zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - + ;; + *.tar.gz|*.tgz) + tar -C "$handoff_dir" -xzf "$asset_path" + ;; + *.zip) + ditto -x -k "$asset_path" "$handoff_dir" + ;; + *) + echo "Unsupported signed macOS handoff archive format: ${asset_name}" + exit 1 + ;; + esac + + echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + + - name: Stage signed macOS artifacts + shell: bash + run: | + set -euo pipefail + + target="${{ matrix.target }}" + artifact_name="${{ matrix.artifact_name }}" + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" + fi + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" + fi + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" + fi + if [[ ! -d "$source_dir" ]]; then + echo "Signed macOS handoff is missing ${artifact_name}/" + echo "Expected either:" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" + find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + exit 1 + fi + + dest="dist/${target}" + mkdir -p "$dest" + + for binary in ${{ matrix.binaries }}; do + source_path="${source_dir}/${binary}" + if [[ ! -f "$source_path" ]]; then + source_path="${source_dir}/${binary}-${target}" + fi + if [[ ! -f "$source_path" ]]; then + echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" + exit 1 + fi + + release_path="${dest}/${binary}-${target}" + ditto "$source_path" "$release_path" + chmod 0755 "$release_path" + codesign --verify --strict --verbose=2 "$release_path" + done + + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then + dmg_name="codex-${target}.dmg" + dmg_source="${source_dir}/${dmg_name}" + if [[ ! -f "$dmg_source" ]]; then + echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" + exit 1 + fi + + codesign --verify --strict --verbose=2 "$dmg_source" + xcrun stapler validate "$dmg_source" + cp "$dmg_source" "$dest/$dmg_name" + fi + + - name: Build Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-apple-darwin) + platform_tag="macosx_11_0_arm64" + ;; + x86_64-apple-darwin) + platform_tag="macosx_10_9_x86_64" + ;; + *) + echo "No Python runtime wheel platform tag for ${{ matrix.target }}" + exit 1 + ;; + esac + + python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + python3 \ + "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ + stage-runtime \ + "$stage_dir" \ + "${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \ + --codex-version "${GITHUB_REF_NAME}" \ + --platform-tag "$platform_tag" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + + - name: Upload Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheel-${{ matrix.target }} + path: python-runtime-dist/${{ matrix.target }}/*.whl + if-no-files-found: error + + - name: Compress artifacts + shell: bash + run: | + set -euo pipefail + + dest="dist/${{ matrix.target }}" + for f in "$dest"/*; do + base="$(basename "$f")" + if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + continue + fi + + tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" + zstd -T0 -19 --rm "$dest/$base" + done + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }} + path: | + codex-rs/dist/${{ matrix.target }}/* + build-windows: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} needs: tag-check uses: ./.github/workflows/rust-release-windows.yml with: @@ -561,6 +855,7 @@ jobs: secrets: inherit argument-comment-lint-release-assets: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} name: argument-comment-lint release assets needs: tag-check uses: ./.github/workflows/rust-release-argument-comment-lint.yml @@ -568,23 +863,53 @@ jobs: publish: true zsh-release-assets: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} name: zsh release assets needs: tag-check uses: ./.github/workflows/rust-release-zsh.yml release: needs: + - tag-check - build + - stage-signed-macos - build-windows - argument-comment-lint-release-assets - zsh-release-assets + if: >- + ${{ + always() && + needs.tag-check.result == 'success' && + ( + ( + github.event_name == 'workflow_dispatch' && + inputs.release_mode == 'promote_signed' && + needs.stage-signed-macos.result == 'success' && + needs.build.result == 'skipped' && + needs.build-windows.result == 'skipped' && + needs.argument-comment-lint-release-assets.result == 'skipped' && + needs.zsh-release-assets.result == 'skipped' + ) || + ( + (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && + needs.build.result == 'success' && + needs.stage-signed-macos.result == 'skipped' && + needs.build-windows.result == 'success' && + needs.argument-comment-lint-release-assets.result == 'success' && + needs.zsh-release-assets.result == 'success' + ) + ) + }} name: release runs-on: ubuntu-latest permissions: contents: write actions: read env: - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} + RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} + SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} @@ -602,6 +927,7 @@ jobs: - name: Define release mode id: release_mode run: | + echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" - name: Generate release notes from tag commit message @@ -628,6 +954,56 @@ jobs: with: path: dist + - name: Download artifacts from unsigned build run + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh run download "$UNSIGNED_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --dir dist + + - name: Remove unsigned macOS staging artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + run: | + set -euo pipefail + find dist -mindepth 1 -maxdepth 1 -type d \ + -name '*-apple-darwin*-unsigned' \ + -exec rm -rf {} + + + - name: Re-upload promoted Linux x64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: x86_64-unknown-linux-musl + path: dist/x86_64-unknown-linux-musl/* + if-no-files-found: error + + - name: Re-upload promoted Linux arm64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: aarch64-unknown-linux-musl + path: dist/aarch64-unknown-linux-musl/* + if-no-files-found: error + + - name: Re-upload promoted Windows x64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: x86_64-pc-windows-msvc + path: dist/x86_64-pc-windows-msvc/* + if-no-files-found: error + + - name: Re-upload promoted Windows arm64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: aarch64-pc-windows-msvc + path: dist/aarch64-pc-windows-msvc/* + if-no-files-found: error + - name: List run: ls -R dist/ @@ -742,8 +1118,10 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | + workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ --release-version "$RELEASE_VERSION" \ + --workflow-url "$workflow_url" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk @@ -761,11 +1139,38 @@ jobs: tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** + overwrite_files: true make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} + - name: Clean up signed promotion handoff assets + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" + gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ + --jq '.[] | [.id, .name] | @tsv' | + while IFS=$'\t' read -r asset_id asset_name; do + if [[ -z "$asset_id" || -z "$asset_name" ]]; then + continue + fi + + delete_asset=false + if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then + delete_asset=true + fi + + if [[ "$delete_asset" == "true" ]]; then + echo "Deleting release asset ${asset_name}" + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" + fi + done + - if: ${{ env.SIGN_MACOS == 'true' }} uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: