diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 02771496fa..7b529f8cf7 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -604,25 +604,12 @@ jobs: # Must run from inside the dest dir so 7z does not embed the # directory path inside the zip. if [[ "$base" == "codex-${target}.exe" ]]; then - # Bundle the sandbox helper binaries into the main codex zip so - # WinGet installs include the required helpers next to codex.exe. - # Fall back to the single-binary zip if the helpers are missing - # to avoid breaking releases. + # Preserve WinGet executable paths while including the signed + # runtime, resources, and package metadata from the canonical tar. bundle_dir="$(mktemp -d)" - runner_src="$dest/codex-command-runner-${target}.exe" - setup_src="$dest/codex-windows-sandbox-setup-${target}.exe" - if [[ -f "$runner_src" && -f "$setup_src" ]]; then - cp "$dest/$base" "$bundle_dir/$base" - cp "$runner_src" "$bundle_dir/codex-command-runner.exe" - cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" - # Use an absolute path so bundle zips land in the real dist - # dir even when 7z runs from a temp directory. - (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) - else - echo "warning: missing sandbox binaries; falling back to single-binary zip" - echo "warning: expected $runner_src and $setup_src" - (cd "$dest" && 7z a "${base}.zip" "$base") - fi + tar -xzf "$dest/codex-package-${target}.tar.gz" -C "$bundle_dir" + python "${GITHUB_WORKSPACE}/scripts/build_winget_package.py" "$(cygpath -w "$bundle_dir")" + (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) rm -rf "$bundle_dir" else (cd "$dest" && 7z a "${base}.zip" "$base") diff --git a/codex-rs/install-context/src/bundle_tests.rs b/codex-rs/install-context/src/bundle_tests.rs index 74a416639b..cf49aa69b0 100644 --- a/codex-rs/install-context/src/bundle_tests.rs +++ b/codex-rs/install-context/src/bundle_tests.rs @@ -46,3 +46,31 @@ fn bundle_executable_preserves_package_layout_and_install_method() -> std::io::R ); Ok(()) } + +#[cfg(windows)] +#[test] +fn winget_root_requires_metadata_for_the_actual_executable() -> std::io::Result<()> { + let temp = tempfile::tempdir()?; + let package = canonical_absolute_path(temp.path()).unwrap(); + let name = "codex-x86_64-pc-windows-msvc.exe"; + let executable = package.join(name); + fs::write(&executable, "signed CLI")?; + for directory in [RESOURCES_DIRNAME, PATH_DIRNAME] { + fs::create_dir_all(package.join(directory))?; + } + assert_eq!(CodexPackageLayout::from_exe(executable.as_path()), None); + for entrypoint in ["other.exe", "bin/codex.exe", name] { + fs::write( + package.join(PACKAGE_METADATA_FILENAME), + serde_json::json!({"layoutVersion": 1, "entrypoint": entrypoint}).to_string(), + )?; + let expected = (entrypoint == name).then(|| CodexPackageLayout { + package_dir: package.clone(), + bin_dir: package.clone(), + resources_dir: Some(package.join(RESOURCES_DIRNAME)), + path_dir: Some(package.join(PATH_DIRNAME)), + }); + assert_eq!(CodexPackageLayout::from_exe(executable.as_path()), expected); + } + Ok(()) +} diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 699add63db..1a9be86a11 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -246,6 +246,21 @@ impl CodexPackageLayout { fn from_exe(exe_path: &Path) -> Option { let canonical_exe = canonical_absolute_path(exe_path)?; let exe_dir = canonical_exe.parent()?; + // WinGet preserves a target-qualified executable at the package root. + // Only recognize that layout when metadata names this exact executable. + #[cfg(windows)] + if let Ok(contents) = std::fs::read(exe_dir.join(PACKAGE_METADATA_FILENAME)) + && let Ok(metadata) = serde_json::from_slice::(&contents) + && metadata["layoutVersion"] == 1 + && metadata["entrypoint"].as_str().map(OsStr::new) == canonical_exe.file_name() + { + return Some(Self { + resources_dir: existing_dir(exe_dir.join(RESOURCES_DIRNAME)), + path_dir: existing_dir(exe_dir.join(PATH_DIRNAME)), + package_dir: exe_dir.clone(), + bin_dir: exe_dir, + }); + } match exe_dir.file_name() { Some(name) if name == OsStr::new(BIN_DIRNAME) => Self::from_package_bin_dir(exe_dir), Some(name) if name == OsStr::new(RESOURCES_DIRNAME) => { diff --git a/scripts/build_winget_package.py b/scripts/build_winget_package.py new file mode 100644 index 0000000000..a5d547048e --- /dev/null +++ b/scripts/build_winget_package.py @@ -0,0 +1,37 @@ +"""Adapt an extracted signed Windows package to WinGet's existing root filenames.""" + +import argparse +import json +import shutil +from pathlib import Path + + +def prepare_winget_package(package: Path) -> None: + metadata_path = package / "codex-package.json" + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + target = metadata["target"] + if target not in ("x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"): + raise ValueError("WinGet requires a Windows package") + if metadata["entrypoint"] != "bin/codex.exe": + raise ValueError("WinGet requires the canonical Codex entrypoint") + entrypoint = f"codex-{target}.exe" + manifest_path = package / "codex-resources/voice/manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["sha256"][entrypoint] = manifest["sha256"].pop("bin/codex.exe") + (package / "bin/codex.exe").rename(package / entrypoint) + (package / "bin/codex-code-mode-host.exe").rename( + package / "codex-code-mode-host.exe" + ) + # Keep resources in place for package-aware discovery and provide the root + # filenames declared by the existing WinGet portable installer manifest. + for helper in ("codex-command-runner.exe", "codex-windows-sandbox-setup.exe"): + shutil.copy2(package / "codex-resources" / helper, package / helper) + metadata["entrypoint"] = entrypoint + for path, value in ((metadata_path, metadata), (manifest_path, manifest)): + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("package", type=Path) + prepare_winget_package(parser.parse_args().package) diff --git a/scripts/codex_package/test_layout.py b/scripts/codex_package/test_layout.py index b0810cac99..b2df9601c1 100644 --- a/scripts/codex_package/test_layout.py +++ b/scripts/codex_package/test_layout.py @@ -1,12 +1,15 @@ #!/usr/bin/env python3 from pathlib import Path +import hashlib +import json import sys import tempfile import unittest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from build_winget_package import prepare_winget_package from codex_package.layout import build_package_dir from codex_package.layout import validate_package_dir from codex_package.targets import PACKAGE_VARIANTS @@ -15,6 +18,69 @@ from codex_package.targets import TARGET_SPECS class PackageLayoutTest(unittest.TestCase): + def test_winget_preserves_signed_files_and_voice_hashes(self) -> None: + for target in ("x86_64-pc-windows-msvc", "aarch64-pc-windows-msvc"): + with self.subTest(target=target), tempfile.TemporaryDirectory() as temp: + package = Path(temp) + files = { + "bin/codex.exe": b"signed CLI", + "bin/codex-code-mode-host.exe": b"signed code mode host", + "codex-resources/codex-command-runner.exe": b"signed runner", + "codex-resources/codex-windows-sandbox-setup.exe": b"signed setup", + "codex-resources/voice/bin/codex-voice-host.exe": b"signed voice host", + "codex-resources/voice/bin/gstreamer-1.0-0.dll": b"signed audio DLL", + "codex-resources/voice/NOTICE.md": b"license notices", + "codex-path/rg.exe": b"ripgrep", + } + for name, contents in files.items(): + path = package / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(contents) + metadata = { + "layoutVersion": 1, + "target": target, + "entrypoint": "bin/codex.exe", + } + (package / "codex-package.json").write_text(json.dumps(metadata)) + manifest = { + "schemaVersion": 1, + "sha256": { + name: hashlib.sha256(contents).hexdigest() + for name, contents in files.items() + if name == "bin/codex.exe" + or name.startswith("codex-resources/voice/") + }, + } + manifest_path = package / "codex-resources/voice/manifest.json" + manifest_path.write_text(json.dumps(manifest)) + prepare_winget_package(package) + entrypoint = f"codex-{target}.exe" + files[entrypoint] = files.pop("bin/codex.exe") + files["codex-code-mode-host.exe"] = files.pop( + "bin/codex-code-mode-host.exe" + ) + for helper in ( + "codex-command-runner.exe", + "codex-windows-sandbox-setup.exe", + ): + files[helper] = files[f"codex-resources/{helper}"] + actual = { + str(path.relative_to(package)).replace("\\", "/"): path.read_bytes() + for path in package.rglob("*") + if path.is_file() + } + actual_metadata = json.loads(actual.pop("codex-package.json")) + actual_manifest = json.loads( + actual.pop("codex-resources/voice/manifest.json") + ) + self.assertEqual(actual, files) + metadata["entrypoint"] = entrypoint + self.assertEqual(actual_metadata, metadata) + manifest["sha256"][entrypoint] = manifest["sha256"].pop("bin/codex.exe") + self.assertEqual(actual_manifest, manifest) + for name, digest in actual_manifest["sha256"].items(): + self.assertEqual(hashlib.sha256(actual[name]).hexdigest(), digest) + def test_macos_package_preserves_prebuilt_resource_binaries(self) -> None: for variant_name in ("codex", "codex-app-server"): for target in ("aarch64-apple-darwin", "x86_64-apple-darwin"):