From 250fa32760fc11ec8104663fa52dc4540b398ba6 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 11 Sep 2026 10:40:22 -0700 Subject: [PATCH 1/3] fix(ci): skip macOS notarization when MACOS_TEAM_ID is empty Fork PRs set MACOS_TEAM_ID to an empty string. The published-binary fixture still passed that value to verify-macos-release.sh, so the npm macos-15 jobs failed before proving install or byte preservation. Keep the notarization check when a 10-character team id is present. Signed-off-by: Sebastien Tardif --- scripts/tests/test_npm_install.py | 53 ++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/scripts/tests/test_npm_install.py b/scripts/tests/test_npm_install.py index 60e0ebd3..9aae22db 100644 --- a/scripts/tests/test_npm_install.py +++ b/scripts/tests/test_npm_install.py @@ -7,6 +7,7 @@ import os from pathlib import Path import platform +import re import signal import subprocess import sys @@ -129,6 +130,13 @@ def installed_native(prefix): return matches[0] +def macos_team_id(value): + team = (value or "").strip() + if re.fullmatch(r"[A-Z0-9]{10}", team): + return team + return None + + class NpmInstallTests(unittest.TestCase): def test_real_global_upgrade_reinstall_local_npx_and_missing_optionals(self): with tempfile.TemporaryDirectory(prefix="ocm-npm-test-") as temporary: @@ -324,6 +332,21 @@ def test_unsupported_target_and_missing_execve_fail_clearly(self): self.assertIn(message, result.stderr) +class MacosTeamIdTests(unittest.TestCase): + def test_accepts_a_ten_character_team_id(self): + self.assertEqual(macos_team_id("AB12CD34EF"), "AB12CD34EF") + + def test_rejects_empty_missing_and_whitespace(self): + self.assertIsNone(macos_team_id("")) + self.assertIsNone(macos_team_id(None)) + self.assertIsNone(macos_team_id(" ")) + + def test_rejects_wrong_shape(self): + self.assertIsNone(macos_team_id("ab12cd34ef")) + self.assertIsNone(macos_team_id("ABC")) + self.assertIsNone(macos_team_id("ABCDEFGHIJK")) + + def smoke(directory): receipt = release.read_receipt(directory) with tempfile.TemporaryDirectory(prefix="ocm-npm-native-") as temporary: @@ -358,18 +381,24 @@ def smoke(directory): ) run([str(entrypoint), "--help"], root, env) if sys.platform == "darwin": - run( - [ - str(release.ROOT / "scripts/verify-macos-release.sh"), - "--binary", - str(binary), - "--team-id", - os.environ["MACOS_TEAM_ID"], - "--require-notarization", - ], - root, - env, - ) + team_id = macos_team_id(os.environ.get("MACOS_TEAM_ID")) + if team_id is None: + print( + "Skipping macOS signature check: MACOS_TEAM_ID is unset or invalid" + ) + else: + run( + [ + str(release.ROOT / "scripts/verify-macos-release.sh"), + "--binary", + str(binary), + "--team-id", + team_id, + "--require-notarization", + ], + root, + env, + ) print( f"Verified npm install, CLI, and unchanged native bytes on {platform.platform()}" ) From 741161839e479d1eab4a7577446a547eac3e0da9 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Fri, 11 Sep 2026 12:57:29 -0700 Subject: [PATCH 2/3] fix(ci): keep npm publish signature checks fail-closed Restrict the empty MACOS_TEAM_ID skip to the fork published-binary fixture. --packages still requires a 10-character team id on macOS before install, matching publish-npm.yml. Signed-off-by: Sebastien Tardif --- scripts/tests/test_npm_install.py | 88 +++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 21 deletions(-) diff --git a/scripts/tests/test_npm_install.py b/scripts/tests/test_npm_install.py index 9aae22db..34267f4a 100644 --- a/scripts/tests/test_npm_install.py +++ b/scripts/tests/test_npm_install.py @@ -137,6 +137,17 @@ def macos_team_id(value): return None +def macos_signature_command(value, *, require_signature): + team_id = macos_team_id(value) + if team_id: + return ["--team-id", team_id, "--require-notarization"] + if require_signature: + raise AssertionError( + "MACOS_TEAM_ID must be a 10-character Apple Developer Team ID" + ) + return None + + class NpmInstallTests(unittest.TestCase): def test_real_global_upgrade_reinstall_local_npx_and_missing_optionals(self): with tempfile.TemporaryDirectory(prefix="ocm-npm-test-") as temporary: @@ -346,8 +357,51 @@ def test_rejects_wrong_shape(self): self.assertIsNone(macos_team_id("ABC")) self.assertIsNone(macos_team_id("ABCDEFGHIJK")) + def test_publication_rejects_empty_team_id(self): + with self.assertRaisesRegex(AssertionError, "10-character"): + macos_signature_command("", require_signature=True) + + def test_fork_fixture_skips_empty_team_id(self): + self.assertIsNone(macos_signature_command("", require_signature=False)) + + def test_valid_team_id_always_verifies(self): + expected = ["--team-id", "AB12CD34EF", "--require-notarization"] + self.assertEqual( + macos_signature_command("AB12CD34EF", require_signature=True), expected + ) + self.assertEqual( + macos_signature_command("AB12CD34EF", require_signature=False), expected + ) + + def test_publication_smoke_rejects_empty_team_id_before_install(self): + if sys.platform != "darwin": + self.skipTest("macOS signature path is darwin-only") + with unittest.mock.patch.dict(os.environ, {"MACOS_TEAM_ID": ""}, clear=False): + with self.assertRaisesRegex(AssertionError, "10-character"): + smoke( + Path("/tmp/ocm-npm-missing-packages"), + require_macos_signature=True, + ) + + def test_fork_fixture_smoke_skips_empty_team_id_before_install(self): + if sys.platform != "darwin": + self.skipTest("macOS signature path is darwin-only") + missing = Path("/tmp/ocm-npm-missing-packages") + with unittest.mock.patch.dict(os.environ, {"MACOS_TEAM_ID": ""}, clear=False): + with self.assertRaises(Exception) as ctx: + smoke(missing, require_macos_signature=False) + self.assertNotRegex(str(ctx.exception), "10-character") + -def smoke(directory): +def smoke(directory, *, require_macos_signature=True): + signature_command = None + if sys.platform == "darwin": + signature_command = macos_signature_command( + os.environ.get("MACOS_TEAM_ID"), + require_signature=require_macos_signature, + ) + if signature_command is None: + print("Skipping macOS signature check: MACOS_TEAM_ID is unset or invalid") receipt = release.read_receipt(directory) with tempfile.TemporaryDirectory(prefix="ocm-npm-native-") as temporary: root = Path(temporary) @@ -380,25 +434,17 @@ def smoke(directory): "installed executable version differs from package" ) run([str(entrypoint), "--help"], root, env) - if sys.platform == "darwin": - team_id = macos_team_id(os.environ.get("MACOS_TEAM_ID")) - if team_id is None: - print( - "Skipping macOS signature check: MACOS_TEAM_ID is unset or invalid" - ) - else: - run( - [ - str(release.ROOT / "scripts/verify-macos-release.sh"), - "--binary", - str(binary), - "--team-id", - team_id, - "--require-notarization", - ], - root, - env, - ) + if signature_command is not None: + run( + [ + str(release.ROOT / "scripts/verify-macos-release.sh"), + "--binary", + str(binary), + *signature_command, + ], + root, + env, + ) print( f"Verified npm install, CLI, and unchanged native bytes on {platform.platform()}" ) @@ -424,7 +470,7 @@ def published_binary_fixture(): (release.ROOT / "npm/README.md").read_bytes(), output, ) - smoke(output) + smoke(output, require_macos_signature=False) if __name__ == "__main__": From 265533fb56c32f143a0f0541bb228909a00021e2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 15 Sep 2026 00:57:37 +0100 Subject: [PATCH 3/3] fix: verify macOS npm fixtures on fork pull requests --- docs/RELEASING.md | 15 +++ scripts/tests/test_npm_install.py | 157 +++++++++++++++++++++--------- 2 files changed, 124 insertions(+), 48 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 9d94c12d..a921cd24 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -55,6 +55,21 @@ ownership guard. The workflow rejects v0.2.39 and other older sources. Future version bumps, signed releases, and publications still require explicit maintainer authorization. +### CI installation fixture + +`scripts/tests/test_npm_install.py --published-binary-fixture` installs the +existing v0.2.39 binaries through a private npm registry and checks their bytes, +version, and CLI. The fixture records that release's verified public Apple Team +ID alongside its version. Both macOS architectures always run the existing +Developer ID, team, identifier, hardened-runtime, timestamp, and notarization +checks, including on fork pull requests without `MACOS_TEAM_ID`. A negative +control also checks that a different expected team is rejected. + +The `--packages` publication check separately requires a valid `MACOS_TEAM_ID`; +it never falls back to the historical fixture's signer. Changing the fixture's +version requires verifying its expected signer as well. Neither fixture mode +nor the local npm tests sign or publish a release. + ### Account setup An npm maintainer with write access to the `openclaw` scope must separately diff --git a/scripts/tests/test_npm_install.py b/scripts/tests/test_npm_install.py index 34267f4a..50e28b6b 100644 --- a/scripts/tests/test_npm_install.py +++ b/scripts/tests/test_npm_install.py @@ -15,6 +15,7 @@ import tempfile from threading import Thread import unittest +from unittest import mock from urllib.parse import unquote from test_npm_release import release, stage_fixture @@ -137,18 +138,38 @@ def macos_team_id(value): return None -def macos_signature_command(value, *, require_signature): +def macos_signature_command(value): team_id = macos_team_id(value) if team_id: return ["--team-id", team_id, "--require-notarization"] - if require_signature: - raise AssertionError( - "MACOS_TEAM_ID must be a 10-character Apple Developer Team ID" - ) - return None + raise AssertionError( + "expected macOS signer must be a 10-character Apple Developer Team ID " + "(MACOS_TEAM_ID for publication)" + ) class NpmInstallTests(unittest.TestCase): + def test_smoke_rejects_changed_installed_bytes_before_execution(self): + with tempfile.TemporaryDirectory(prefix="ocm-npm-tamper-") as temporary: + packages, _ = stage_fixture( + Path(temporary), binary=b"#!/bin/sh\nprintf '1.0.0\\n'\n" + ) + find_native = installed_native + + def change_installed_bytes(prefix): + binary = find_native(prefix) + with binary.open("ab") as file: + file.write(b"\n# changed after npm installation\n") + return binary + + with mock.patch( + f"{__name__}.installed_native", side_effect=change_installed_bytes + ): + with self.assertRaisesRegex( + AssertionError, "npm install changed native executable bytes" + ): + smoke(packages, expected_macos_team_id="AB12CD34EF") + def test_real_global_upgrade_reinstall_local_npx_and_missing_optionals(self): with tempfile.TemporaryDirectory(prefix="ocm-npm-test-") as temporary: root = Path(temporary) @@ -357,51 +378,57 @@ def test_rejects_wrong_shape(self): self.assertIsNone(macos_team_id("ABC")) self.assertIsNone(macos_team_id("ABCDEFGHIJK")) - def test_publication_rejects_empty_team_id(self): - with self.assertRaisesRegex(AssertionError, "10-character"): - macos_signature_command("", require_signature=True) - - def test_fork_fixture_skips_empty_team_id(self): - self.assertIsNone(macos_signature_command("", require_signature=False)) - def test_valid_team_id_always_verifies(self): expected = ["--team-id", "AB12CD34EF", "--require-notarization"] - self.assertEqual( - macos_signature_command("AB12CD34EF", require_signature=True), expected - ) - self.assertEqual( - macos_signature_command("AB12CD34EF", require_signature=False), expected - ) - - def test_publication_smoke_rejects_empty_team_id_before_install(self): - if sys.platform != "darwin": - self.skipTest("macOS signature path is darwin-only") - with unittest.mock.patch.dict(os.environ, {"MACOS_TEAM_ID": ""}, clear=False): - with self.assertRaisesRegex(AssertionError, "10-character"): - smoke( - Path("/tmp/ocm-npm-missing-packages"), - require_macos_signature=True, - ) - - def test_fork_fixture_smoke_skips_empty_team_id_before_install(self): - if sys.platform != "darwin": - self.skipTest("macOS signature path is darwin-only") - missing = Path("/tmp/ocm-npm-missing-packages") - with unittest.mock.patch.dict(os.environ, {"MACOS_TEAM_ID": ""}, clear=False): - with self.assertRaises(Exception) as ctx: - smoke(missing, require_macos_signature=False) - self.assertNotRegex(str(ctx.exception), "10-character") - - -def smoke(directory, *, require_macos_signature=True): + self.assertEqual(macos_signature_command("AB12CD34EF"), expected) + self.assertEqual(macos_signature_command(" AB12CD34EF\n"), expected) + + def test_publication_requires_its_own_valid_team_id_before_install(self): + with tempfile.TemporaryDirectory(prefix="ocm-npm-team-") as temporary: + missing = Path(temporary) / "missing-packages" + for value in [None, "", " ", "invalid", "ABCDEFGHIJK"]: + with self.subTest(value=value), mock.patch.dict(os.environ): + os.environ.pop("MACOS_TEAM_ID", None) + if value is not None: + os.environ["MACOS_TEAM_ID"] = value + with mock.patch.object(sys, "platform", "darwin"): + with self.assertRaisesRegex(AssertionError, "MACOS_TEAM_ID"): + smoke(missing) + + def test_fixture_signer_does_not_depend_on_publication_configuration(self): + with tempfile.TemporaryDirectory(prefix="ocm-npm-team-") as temporary: + missing = Path(temporary) / "missing-packages" + for value in [None, "", "invalid", "ZZ99YY88XX"]: + with self.subTest(value=value), mock.patch.dict(os.environ): + os.environ.pop("MACOS_TEAM_ID", None) + if value is not None: + os.environ["MACOS_TEAM_ID"] = value + with mock.patch.object(sys, "platform", "darwin"): + with self.assertRaises(FileNotFoundError) as failure: + smoke(missing, expected_macos_team_id="AB12CD34EF") + self.assertEqual( + failure.exception.filename, str(missing / "release.json") + ) + + def test_invalid_explicit_signer_never_falls_back_to_publication(self): + with tempfile.TemporaryDirectory(prefix="ocm-npm-team-") as temporary: + missing = Path(temporary) / "missing-packages" + with mock.patch.dict(os.environ, {"MACOS_TEAM_ID": "AB12CD34EF"}): + with mock.patch.object(sys, "platform", "darwin"): + for value in ["", " ", "invalid"]: + with self.subTest(value=value): + with self.assertRaisesRegex(AssertionError, "10-character"): + smoke(missing, expected_macos_team_id=value) + + +def smoke(directory, *, expected_macos_team_id=None): signature_command = None if sys.platform == "darwin": signature_command = macos_signature_command( - os.environ.get("MACOS_TEAM_ID"), - require_signature=require_macos_signature, + expected_macos_team_id + if expected_macos_team_id is not None + else os.environ.get("MACOS_TEAM_ID") ) - if signature_command is None: - print("Skipping macOS signature check: MACOS_TEAM_ID is unset or invalid") receipt = release.read_receipt(directory) with tempfile.TemporaryDirectory(prefix="ocm-npm-native-") as temporary: root = Path(temporary) @@ -445,6 +472,7 @@ def smoke(directory, *, require_macos_signature=True): root, env, ) + print("Verified macOS signature and notarization after npm installation") print( f"Verified npm install, CLI, and unchanged native bytes on {platform.platform()}" ) @@ -453,7 +481,10 @@ def smoke(directory, *, require_macos_signature=True): def published_binary_fixture(): # This old release proves signed-byte preservation only. Production prepare # rejects it because the native npm ownership guard had not shipped. - repo, tag = "openclaw/ocm", "v0.2.39" + # Public expected signer of this pinned release, verified on both macOS + # architectures. It is independent of the current publisher's configuration. + repo, version, team_id = "openclaw/ocm", "0.2.39", "FWJYW4S8P8" + tag = f"v{version}" snapshot = release.release_snapshot(repo, tag) with tempfile.TemporaryDirectory(prefix="ocm-npm-release-fixture-") as temporary: root = Path(temporary) @@ -462,7 +493,7 @@ def published_binary_fixture(): release.download_assets(repo, tag, snapshot, assets) output = root / "npm" release.stage( - "0.2.39", + version, {"repository": repo, "tag": tag, "assets": snapshot}, assets, (release.ROOT / "npm/ocm.cjs").read_bytes(), @@ -470,7 +501,37 @@ def published_binary_fixture(): (release.ROOT / "npm/README.md").read_bytes(), output, ) - smoke(output, require_macos_signature=False) + smoke(output, expected_macos_team_id=team_id) + if sys.platform == "darwin": + # Reuse the downloaded fixture to prove a different expected team + # cannot pass the same verifier used for the installed executable. + target = release.TARGETS[ + "darwin-arm64" if platform.machine() == "arm64" else "darwin-x64" + ] + binary = root / "wrong-team-ocm" + binary.write_bytes(release.native_bytes(assets / f"ocm-{target}.tar.gz")) + binary.chmod(0o755) + wrong_team = "AAAAAAAAAA" if team_id != "AAAAAAAAAA" else "BBBBBBBBBB" + result = subprocess.run( + [ + str(release.ROOT / "scripts/verify-macos-release.sh"), + "--binary", + str(binary), + *macos_signature_command(wrong_team), + ], + cwd=root, + text=True, + capture_output=True, + timeout=90, + ) + if result.returncode == 0 or ( + f"not signed by Apple Developer Team {wrong_team}" not in result.stderr + ): + raise AssertionError( + "wrong-signer control did not reject the team: " + f"{result.stdout}{result.stderr}" + ) + print("Verified that a different expected macOS signer is rejected") if __name__ == "__main__":