claude-multiacc 2.0.66 → 2.0.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/docs/INSTALLATION.md +4 -3
  2. package/docs/RELEASING.md +75 -0
  3. package/package.json +1 -1
  4. package/tests/autoresume_cross_tmux_environment.py +4 -1
  5. package/tests/run-tests.sh +3 -1
  6. package/tests/test_release_validation.py +894 -0
  7. package/lib/__pycache__/audit.cpython-314.pyc +0 -0
  8. package/lib/__pycache__/autoresume.cpython-314.pyc +0 -0
  9. package/lib/__pycache__/autoresume_cross.cpython-314.pyc +0 -0
  10. package/lib/__pycache__/autoresume_cross_dispatch.cpython-314.pyc +0 -0
  11. package/lib/__pycache__/autoresume_cross_receipt.cpython-314.pyc +0 -0
  12. package/lib/__pycache__/autoresume_handoff.cpython-314.pyc +0 -0
  13. package/lib/__pycache__/autoresume_probe.cpython-314.pyc +0 -0
  14. package/lib/__pycache__/autoresume_receipt.cpython-314.pyc +0 -0
  15. package/lib/__pycache__/claude_client_error.cpython-314.pyc +0 -0
  16. package/lib/__pycache__/claude_reset.cpython-314.pyc +0 -0
  17. package/lib/__pycache__/claude_token_identity.cpython-314.pyc +0 -0
  18. package/lib/__pycache__/codex_autoresume_mode.cpython-314.pyc +0 -0
  19. package/lib/__pycache__/codex_config_edit.cpython-314.pyc +0 -0
  20. package/lib/__pycache__/codex_credit_controls.cpython-314.pyc +0 -0
  21. package/lib/__pycache__/codex_credit_local_scope.cpython-314.pyc +0 -0
  22. package/lib/__pycache__/codex_credit_selection.cpython-314.pyc +0 -0
  23. package/lib/__pycache__/codex_credits.cpython-314.pyc +0 -0
  24. package/lib/__pycache__/codex_limits_lock.cpython-314.pyc +0 -0
  25. package/lib/__pycache__/codex_mcp_oauth.cpython-314.pyc +0 -0
  26. package/lib/__pycache__/codex_python.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/codex_resume_cwd.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/codex_speed.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/codex_trust.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/codex_usage_fetch.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/keychain.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/mcp_mirror.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/mcp_oauth.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/mcp_registry.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/model_catalog.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/model_match.cpython-314.pyc +0 -0
  37. package/lib/__pycache__/report.cpython-314.pyc +0 -0
  38. package/lib/__pycache__/selector_credit_scope.cpython-314.pyc +0 -0
  39. package/lib/__pycache__/selector_credits.cpython-314.pyc +0 -0
  40. package/lib/__pycache__/selector_funding.cpython-314.pyc +0 -0
  41. package/lib/__pycache__/selector_policy.cpython-314.pyc +0 -0
  42. package/lib/__pycache__/selector_primitives.cpython-314.pyc +0 -0
  43. package/lib/__pycache__/session_settings.cpython-314.pyc +0 -0
  44. package/lib/__pycache__/shim_path.cpython-314.pyc +0 -0
  45. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-314.pyc +0 -0
  46. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-314.pyc +0 -0
  47. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-314.pyc +0 -0
  48. package/tests/__pycache__/autoresume_cross_unit_support.cpython-314.pyc +0 -0
  49. package/tests/__pycache__/autoresume_tmux_support.cpython-314.pyc +0 -0
  50. package/tests/__pycache__/packaged_command_support.cpython-314.pyc +0 -0
  51. package/tests/__pycache__/test_autoresume.cpython-314.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume_catalog.cpython-314.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-314.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_codex_records.cpython-314.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_codex_trust.cpython-314.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-314.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_guards.cpython-314.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cross_holders.cpython-314.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_cross_origin.cpython-314.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_cross_policy.cpython-314.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-314.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-314.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-314.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-314.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_cursor.cpython-314.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_cwd.cpython-314.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-314.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_cwd_reader.cpython-314.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-314.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_fallback.cpython-314.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-314.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_handoff_history.cpython-314.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-314.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_memory.cpython-314.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-314.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_mode.cpython-314.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_probe.cpython-314.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_receipt.cpython-314.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-314.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_settings.cpython-314.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_sidechain.cpython-314.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-314.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_tmux.cpython-314.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-314.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-314.pyc +0 -0
  86. package/tests/__pycache__/test_claude_oauth_lock.cpython-314.pyc +0 -0
  87. package/tests/__pycache__/test_claude_reset.cpython-314.pyc +0 -0
  88. package/tests/__pycache__/test_claude_token_identity.cpython-314.pyc +0 -0
  89. package/tests/__pycache__/test_codex_exec_stdin.cpython-314.pyc +0 -0
  90. package/tests/__pycache__/test_codex_mcp_oauth.cpython-314.pyc +0 -0
  91. package/tests/__pycache__/test_codex_reset.cpython-314.pyc +0 -0
  92. package/tests/__pycache__/test_codex_reset_credits.cpython-314.pyc +0 -0
  93. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-314.pyc +0 -0
  94. package/tests/__pycache__/test_codex_reset_credits_report.cpython-314.pyc +0 -0
  95. package/tests/__pycache__/test_codex_reset_fetch.cpython-314.pyc +0 -0
  96. package/tests/__pycache__/test_codex_reset_polling.cpython-314.pyc +0 -0
  97. package/tests/__pycache__/test_codex_reset_reporting.cpython-314.pyc +0 -0
  98. package/tests/__pycache__/test_codex_reset_windows.cpython-314.pyc +0 -0
  99. package/tests/__pycache__/test_mcp_mirror.cpython-314.pyc +0 -0
  100. package/tests/__pycache__/test_mcp_mirror_args.cpython-314.pyc +0 -0
  101. package/tests/__pycache__/test_mcp_oauth.cpython-314.pyc +0 -0
  102. package/tests/__pycache__/test_mcp_registry.cpython-314.pyc +0 -0
  103. package/tests/__pycache__/test_selector.cpython-314.pyc +0 -0
  104. package/tests/__pycache__/test_selector_cached_credits.cpython-314.pyc +0 -0
  105. package/tests/__pycache__/test_selector_credit_scope.cpython-314.pyc +0 -0
  106. package/tests/__pycache__/test_selector_credit_shim.cpython-314.pyc +0 -0
  107. package/tests/__pycache__/test_selector_credits.cpython-314.pyc +0 -0
  108. package/tests/__pycache__/test_selector_spread.cpython-314.pyc +0 -0
  109. package/tests/__pycache__/test_token_identity_ceremony.cpython-314.pyc +0 -0
  110. package/tests/__pycache__/test_token_identity_integration.cpython-314.pyc +0 -0
  111. package/tests/__pycache__/test_token_identity_retry.cpython-314.pyc +0 -0
  112. package/tests/__pycache__/test_token_identity_sync.cpython-314.pyc +0 -0
@@ -0,0 +1,894 @@
1
+ #!/usr/bin/env python3
2
+ """Fail closed when release evidence does not match its author and exact source."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import copy
7
+ import base64
8
+ from datetime import datetime, timedelta, timezone
9
+ import hashlib
10
+ import io
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import subprocess
15
+ import sys
16
+ import tarfile
17
+ import tempfile
18
+ import time
19
+ import unittest
20
+ from unittest import mock
21
+
22
+ ROOT = Path(__file__).resolve().parents[1]
23
+ sys.path.insert(0, str(ROOT / "scripts"))
24
+
25
+ import release_validation as validation # noqa: E402
26
+ import publish_validated as publisher # noqa: E402
27
+
28
+
29
+ class AuthenticatedReleaseTests(unittest.TestCase):
30
+ NOW = datetime(2026, 10, 25, 12, 0, 0, tzinfo=timezone.utc)
31
+ TREE = "a" * 40
32
+ VERSION = "2.0.66"
33
+ ARTIFACT = "b" * 64
34
+ ACTOR = "release-maintainer"
35
+
36
+ def status(self, context, description, *, age=60, state="success", actor=None, sid=1):
37
+ stamp = (self.NOW - timedelta(seconds=age)).isoformat().replace("+00:00", "Z")
38
+ return {
39
+ "id": sid,
40
+ "context": context,
41
+ "description": description,
42
+ "state": state,
43
+ "creator": {"login": self.ACTOR if actor is None else actor},
44
+ "created_at": stamp,
45
+ "updated_at": stamp,
46
+ }
47
+
48
+ def statuses(self):
49
+ return [
50
+ self.status(validation.CONTEXT,
51
+ f"v1:{self.TREE}:{validation.POLICY_SHA256}"),
52
+ self.status(validation.ARTIFACT_CONTEXT,
53
+ f"v1:{self.VERSION}:{self.ARTIFACT}", sid=2),
54
+ ]
55
+
56
+ def authenticate(self, statuses, **overrides):
57
+ arguments = {
58
+ "tree": self.TREE,
59
+ "version": self.VERSION,
60
+ "artifact_sha256": self.ARTIFACT,
61
+ "actor": self.ACTOR,
62
+ "now": self.NOW,
63
+ }
64
+ arguments.update(overrides)
65
+ return validation.authenticate(statuses, **arguments)
66
+
67
+ def test_matching_authenticated_evidence_is_accepted(self):
68
+ self.assertIsInstance(self.authenticate(self.statuses()), dict)
69
+
70
+ def test_both_independent_statuses_are_required(self):
71
+ statuses = self.statuses()
72
+ for evidence in ([], statuses[:1], statuses[1:]):
73
+ with self.subTest(contexts=[s["context"] for s in evidence]):
74
+ with self.assertRaises(ValueError):
75
+ self.authenticate(evidence)
76
+
77
+ def test_either_status_must_succeed_and_come_from_the_required_actor(self):
78
+ for index in range(2):
79
+ for field, value in (("state", "failure"), ("state", "error"),
80
+ ("state", "pending"),
81
+ ("creator", {"login": "another-user"}),
82
+ ("creator", {})):
83
+ with self.subTest(index=index, field=field, value=value):
84
+ statuses = self.statuses()
85
+ statuses[index][field] = value
86
+ with self.assertRaises(ValueError):
87
+ self.authenticate(statuses)
88
+
89
+ def test_evidence_binds_tree_policy_version_and_artifact(self):
90
+ variants = (
91
+ (0, f"v1:{'c' * 40}:{validation.POLICY_SHA256}"),
92
+ (0, f"v1:{self.TREE}:{'c' * 64}"),
93
+ (1, f"v1:2.0.67:{self.ARTIFACT}"),
94
+ (1, f"v1:{self.VERSION}:{'c' * 64}"),
95
+ )
96
+ for index, description in variants:
97
+ with self.subTest(index=index, description=description):
98
+ statuses = self.statuses()
99
+ statuses[index]["description"] = description
100
+ with self.assertRaises(ValueError):
101
+ self.authenticate(statuses)
102
+
103
+ def test_changed_release_inputs_cannot_reuse_old_evidence(self):
104
+ for override in ({"tree": "c" * 40}, {"version": "2.0.67"},
105
+ {"artifact_sha256": "c" * 64}, {"actor": "another-user"}):
106
+ with self.subTest(override=override):
107
+ with self.assertRaises(ValueError):
108
+ self.authenticate(self.statuses(), **override)
109
+
110
+ def test_invalid_expected_release_identity_is_rejected(self):
111
+ for override in ({"tree": "not-a-tree"}, {"artifact_sha256": "short"},
112
+ {"version": "latest"}, {"actor": ""}, {"actor": " "}):
113
+ with self.subTest(override=override):
114
+ with self.assertRaises(ValueError):
115
+ self.authenticate(self.statuses(), **override)
116
+
117
+ def test_malformed_or_extended_descriptions_are_not_evidence(self):
118
+ for index in range(2):
119
+ for transform in (lambda s: s.replace("v1:", "v2:", 1),
120
+ lambda s: s + ":extra", lambda s: "", lambda s: None):
121
+ statuses = self.statuses()
122
+ statuses[index]["description"] = transform(statuses[index]["description"])
123
+ with self.subTest(index=index, description=statuses[index]["description"]):
124
+ with self.assertRaises(ValueError):
125
+ self.authenticate(statuses)
126
+
127
+ def test_newer_non_success_or_untrusted_status_invalidates_old_success(self):
128
+ for index in range(2):
129
+ for change in ({"state": "failure"}, {"state": "pending"},
130
+ {"creator": {"login": "another-user"}}):
131
+ statuses = self.statuses()
132
+ newest = copy.deepcopy(statuses[index])
133
+ newest.update(change)
134
+ newest["id"] = 100
135
+ newest["created_at"] = self.NOW.isoformat()
136
+ newest["updated_at"] = newest["created_at"]
137
+ # Do not accidentally accept the first matching success in API input.
138
+ for ordered in (statuses + [newest], [newest] + statuses):
139
+ with self.subTest(index=index, change=change, newest_first=ordered[0] is newest):
140
+ with self.assertRaises(ValueError):
141
+ self.authenticate(ordered)
142
+
143
+ def test_fresh_success_supersedes_an_older_failure(self):
144
+ statuses = self.statuses()
145
+ old = copy.deepcopy(statuses[0])
146
+ old["state"] = "failure"
147
+ old["created_at"] = (self.NOW - timedelta(hours=2)).isoformat()
148
+ old["updated_at"] = old["created_at"]
149
+ self.assertIsInstance(self.authenticate([old] + statuses), dict)
150
+
151
+ def test_status_id_breaks_a_same_second_timestamp_tie(self):
152
+ statuses = self.statuses()
153
+ newest = copy.deepcopy(statuses[0])
154
+ newest.update(id=100, state="failure")
155
+ for ordered in (statuses + [newest], [newest] + statuses):
156
+ with self.subTest(newest_first=ordered[0] is newest):
157
+ with self.assertRaises(ValueError):
158
+ self.authenticate(ordered)
159
+
160
+ def test_stale_future_or_unparseable_timestamps_are_rejected(self):
161
+ invalid = (
162
+ (self.NOW - timedelta(hours=24, seconds=1)).isoformat(),
163
+ (self.NOW + timedelta(seconds=301)).isoformat(),
164
+ "not-a-timestamp",
165
+ None,
166
+ )
167
+ for index in range(2):
168
+ for stamp in invalid:
169
+ statuses = self.statuses()
170
+ statuses[index]["created_at"] = stamp
171
+ statuses[index]["updated_at"] = stamp
172
+ with self.subTest(index=index, stamp=stamp):
173
+ with self.assertRaises(ValueError):
174
+ self.authenticate(statuses)
175
+
176
+ def test_documented_freshness_boundaries_are_inclusive(self):
177
+ for age in (24 * 3600, -300):
178
+ statuses = self.statuses()
179
+ stamp = (self.NOW - timedelta(seconds=age)).isoformat()
180
+ for status in statuses:
181
+ status["created_at"] = stamp
182
+ status["updated_at"] = stamp
183
+ with self.subTest(age=age):
184
+ self.assertIsInstance(self.authenticate(statuses), dict)
185
+
186
+ def test_unrelated_statuses_do_not_substitute_for_required_contexts(self):
187
+ extra = self.status("unrelated-check", "anything", state="failure", actor="another-user")
188
+ self.assertIsInstance(self.authenticate([extra] + self.statuses()), dict)
189
+ with self.assertRaises(ValueError):
190
+ self.authenticate([extra])
191
+
192
+
193
+ class RepositoryFixture:
194
+ def setUp(self):
195
+ self.tmp = tempfile.TemporaryDirectory(prefix="multiacc-release-identity-")
196
+ self.addCleanup(self.tmp.cleanup)
197
+ self.root = Path(self.tmp.name)
198
+ self.env = {**os.environ, "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull,
199
+ "GIT_AUTHOR_NAME": "Release fixture", "GIT_AUTHOR_EMAIL": "release@example.invalid",
200
+ "GIT_COMMITTER_NAME": "Release fixture", "GIT_COMMITTER_EMAIL": "release@example.invalid"}
201
+ self.git("init", "-q")
202
+ (self.root / "CLAUDE.md").write_text("Fixture instructions.\n")
203
+ (self.root / "OTHER.md").write_text("Other fixture instructions.\n")
204
+ (self.root / "AGENTS.md").symlink_to("CLAUDE.md")
205
+ self.commit()
206
+
207
+ def git(self, *args):
208
+ return subprocess.check_output(["git", "-C", str(self.root), *args],
209
+ env=self.env, text=True, stderr=subprocess.STDOUT).strip()
210
+
211
+ def commit(self):
212
+ self.git("add", "--all")
213
+ self.git("commit", "-qm", "Fixture source")
214
+
215
+
216
+ class GitIdentityTests(RepositoryFixture, unittest.TestCase):
217
+ def test_clean_repository_identity_uses_committed_head_and_tree(self):
218
+ identity = validation.git_identity(self.root)
219
+ self.assertEqual(identity["head"], self.git("rev-parse", "HEAD"))
220
+ self.assertEqual(identity["tree"], self.git("rev-parse", "HEAD^{tree}"))
221
+
222
+ def test_staged_unstaged_and_untracked_changes_are_rejected(self):
223
+ (self.root / "CLAUDE.md").write_text("Changed source.\n")
224
+ with self.assertRaises(ValueError):
225
+ validation.git_identity(self.root)
226
+ self.git("add", "CLAUDE.md")
227
+ with self.assertRaises(ValueError):
228
+ validation.git_identity(self.root)
229
+ self.commit()
230
+ (self.root / "untracked-source.py").write_text("print('unvalidated')\n")
231
+ with self.assertRaises(ValueError):
232
+ validation.git_identity(self.root)
233
+
234
+ def test_symlink_target_change_changes_identity_and_invalidates_receipt(self):
235
+ previous = validation.git_identity(self.root)
236
+ (self.root / "AGENTS.md").unlink()
237
+ (self.root / "AGENTS.md").symlink_to("OTHER.md")
238
+ self.commit()
239
+ current = validation.git_identity(self.root)
240
+ self.assertNotEqual(previous["tree"], current["tree"])
241
+ case = AuthenticatedReleaseTests()
242
+ statuses = case.statuses()
243
+ statuses[0]["description"] = f"v1:{previous['tree']}:{validation.POLICY_SHA256}"
244
+ with self.assertRaises(ValueError):
245
+ case.authenticate(statuses, tree=current["tree"])
246
+
247
+
248
+ class ReleaseEvidenceTests(RepositoryFixture, unittest.TestCase):
249
+ REPOSITORY = "owner/claude-multiacc"
250
+ ACTOR = "release-maintainer"
251
+ VERSION = "2.0.66"
252
+
253
+ def setUp(self):
254
+ super().setUp()
255
+ (self.root / "package.json").write_text(json.dumps(
256
+ {"name": "claude-multiacc", "version": self.VERSION}))
257
+ self.commit()
258
+ self.identity = validation.git_identity(self.root)
259
+ evidence = tempfile.TemporaryDirectory(prefix="multiacc-release-evidence-")
260
+ self.addCleanup(evidence.cleanup)
261
+ self.evidence = Path(evidence.name)
262
+ self.receipt = self.evidence / "receipt.json"
263
+ (self.evidence / "artifact").mkdir()
264
+ self.tarball = self.evidence / "artifact" / f"claude-multiacc-{self.VERSION}.tgz"
265
+ self.tarball.write_bytes(b"validated package fixture")
266
+ self.artifact = {"filename": self.tarball.name, "sha256": validation.sha256(self.tarball),
267
+ "integrity": "sha512-validated-fixture"}
268
+ self.record = {"schema": 1, **self.identity, "policy_sha256": validation.POLICY_SHA256,
269
+ "version": self.VERSION, "completed_at": time.time(),
270
+ "checks": [], "artifact": self.artifact}
271
+ for name, command in validation.CHECKS:
272
+ log = self.evidence / f"{name}.log"
273
+ log.write_text(f"Passed {name}\n")
274
+ self.record["checks"].append({"name": name, "command": list(command),
275
+ "returncode": 0, "log": log.name,
276
+ "log_sha256": validation.sha256(log)})
277
+ self.write_receipt()
278
+ self.posts = []
279
+ self.remote_tree = self.identity["tree"]
280
+ self.pulls = []
281
+ self.environment_patch = mock.patch.dict(os.environ, {"GITHUB_ACTIONS": ""})
282
+ self.environment_patch.start()
283
+ self.addCleanup(self.environment_patch.stop)
284
+ self.api_patch = mock.patch.object(validation, "github", side_effect=self.github)
285
+ self.api = self.api_patch.start()
286
+ self.addCleanup(self.api_patch.stop)
287
+
288
+ def write_receipt(self):
289
+ self.receipt.write_text(json.dumps(self.record))
290
+
291
+ def status(self, context, description, sid):
292
+ return {"context": context, "description": description, "id": sid,
293
+ "state": "success", "creator": {"login": self.ACTOR},
294
+ "created_at": datetime.now(timezone.utc).isoformat()}
295
+
296
+ def statuses(self):
297
+ return [self.status(validation.CONTEXT,
298
+ f"v1:{self.identity['tree']}:{validation.POLICY_SHA256}", 1),
299
+ self.status(validation.ARTIFACT_CONTEXT,
300
+ f"v1:{self.VERSION}:{self.artifact['sha256']}", 2)]
301
+
302
+ def github(self, endpoint, payload=None):
303
+ if payload is not None:
304
+ self.posts.append((endpoint, payload))
305
+ return self.status(payload["context"], payload["description"], len(self.posts))
306
+ if endpoint == "user":
307
+ return {"login": self.ACTOR}
308
+ if "/git/commits/" in endpoint:
309
+ return {"tree": {"sha": self.remote_tree}}
310
+ if endpoint.endswith("/pulls?per_page=100"):
311
+ return self.pulls
312
+ if endpoint.endswith("/statuses?per_page=100"):
313
+ return self.statuses()
314
+ raise AssertionError(f"unexpected GitHub fixture endpoint: {endpoint}")
315
+
316
+ def publish(self):
317
+ return validation.publish(self.root, self.REPOSITORY, self.receipt)
318
+
319
+ def assert_no_attestation(self, error=ValueError):
320
+ with self.assertRaises(error):
321
+ self.publish()
322
+ self.api.assert_not_called()
323
+ self.assertEqual(self.posts, [])
324
+
325
+ def test_valid_receipt_posts_both_exact_head_attestations(self):
326
+ result = self.publish()
327
+ self.assertEqual(result["tree"], self.identity["tree"])
328
+ self.assertEqual(len(self.posts), 2)
329
+ for endpoint, payload in self.posts:
330
+ self.assertEqual(endpoint, f"repos/{self.REPOSITORY}/statuses/{self.identity['head']}")
331
+ self.assertEqual(payload["state"], "success")
332
+ self.assertEqual({p[1]["context"] for p in self.posts},
333
+ {validation.CONTEXT, validation.ARTIFACT_CONTEXT})
334
+
335
+ def test_dirty_source_never_posts_an_attestation(self):
336
+ (self.root / "CLAUDE.md").write_text("Unvalidated source\n")
337
+ self.assert_no_attestation()
338
+
339
+ def test_new_committed_source_cannot_reuse_a_receipt(self):
340
+ (self.root / "CLAUDE.md").write_text("New committed source\n")
341
+ self.commit()
342
+ self.assert_no_attestation()
343
+
344
+ def test_missing_check_or_changed_command_cannot_attest(self):
345
+ original = copy.deepcopy(self.record)
346
+ for modification in ("missing", "command", "failed", "boolean-returncode"):
347
+ self.record = copy.deepcopy(original)
348
+ if modification == "missing":
349
+ self.record["checks"].pop()
350
+ elif modification == "command":
351
+ self.record["checks"][0]["command"] = ["true"]
352
+ elif modification == "failed":
353
+ self.record["checks"][0]["returncode"] = 1
354
+ else:
355
+ self.record["checks"][0]["returncode"] = False
356
+ self.write_receipt()
357
+ with self.subTest(modification=modification):
358
+ self.assert_no_attestation()
359
+
360
+ def test_changed_or_missing_logs_cannot_attest(self):
361
+ log = self.evidence / self.record["checks"][0]["log"]
362
+ log.write_text("A replacement log\n")
363
+ self.assert_no_attestation()
364
+ log.unlink()
365
+ self.assert_no_attestation(OSError)
366
+
367
+ def test_changed_artifact_cannot_attest(self):
368
+ self.tarball.write_bytes(b"changed package")
369
+ self.assert_no_attestation()
370
+
371
+ def test_stale_or_future_receipt_cannot_attest(self):
372
+ for age in (validation.MAX_AGE_SECONDS + 60, -600):
373
+ self.record["completed_at"] = time.time() - age
374
+ self.write_receipt()
375
+ with self.subTest(age=age):
376
+ self.assert_no_attestation()
377
+
378
+ def test_remote_source_mismatch_never_posts_success(self):
379
+ self.remote_tree = "c" * 40
380
+ with self.assertRaises(ValueError):
381
+ self.publish()
382
+ self.assertEqual(self.posts, [])
383
+
384
+ def packed(self, root, output):
385
+ Path(output).mkdir(parents=True, exist_ok=True)
386
+ (Path(output) / self.artifact["filename"]).write_bytes(self.tarball.read_bytes())
387
+ return dict(self.artifact)
388
+
389
+ def verify(self):
390
+ return validation.verify(self.root, self.REPOSITORY, self.ACTOR, self.evidence / "verified")
391
+
392
+ def test_direct_commit_verifies_its_own_statuses(self):
393
+ with mock.patch.object(validation, "pack", side_effect=self.packed):
394
+ result = self.verify()
395
+ self.assertEqual(result["validation_head"], self.identity["head"])
396
+ self.assertTrue((self.evidence / "verified" / "release.json").is_file())
397
+ self.assertEqual(self.posts, [])
398
+
399
+ def merged_pull(self, head=None, repository=None):
400
+ return {"merged_at": datetime.now(timezone.utc).isoformat(),
401
+ "merge_commit_sha": self.identity["head"],
402
+ "head": {"sha": head or "b" * 40,
403
+ "repo": {"full_name": repository or self.REPOSITORY}}}
404
+
405
+ def test_merged_commit_uses_matching_original_pr_head_evidence(self):
406
+ self.pulls = [self.merged_pull()]
407
+ with mock.patch.object(validation, "pack", side_effect=self.packed):
408
+ result = self.verify()
409
+ self.assertEqual(result["head"], self.identity["head"])
410
+ self.assertEqual(result["validation_head"], "b" * 40)
411
+ self.assertIn(mock.call(f"repos/{self.REPOSITORY}/commits/{'b' * 40}/statuses?per_page=100"),
412
+ self.api.call_args_list)
413
+
414
+ def test_merge_tree_mismatch_rejects_before_packing(self):
415
+ self.pulls = [self.merged_pull()]
416
+ self.remote_tree = "c" * 40
417
+ with mock.patch.object(validation, "pack") as pack:
418
+ with self.assertRaises(ValueError):
419
+ self.verify()
420
+ pack.assert_not_called()
421
+ self.assertEqual(self.posts, [])
422
+
423
+ def test_foreign_or_ambiguous_pr_head_is_rejected(self):
424
+ for pulls in ([self.merged_pull(repository="another/repository")],
425
+ [self.merged_pull(), self.merged_pull(head="c" * 40)]):
426
+ self.pulls = pulls
427
+ with self.subTest(pulls=pulls), mock.patch.object(validation, "pack") as pack:
428
+ with self.assertRaises(ValueError):
429
+ self.verify()
430
+ pack.assert_not_called()
431
+
432
+ def test_source_changed_during_pack_never_produces_verified_record(self):
433
+ def changed_pack(root, output):
434
+ artifact = self.packed(root, output)
435
+ (root / "CLAUDE.md").write_text("Source changed during package creation\n")
436
+ return artifact
437
+ with mock.patch.object(validation, "pack", side_effect=changed_pack):
438
+ with self.assertRaises(ValueError):
439
+ self.verify()
440
+ self.assertFalse((self.evidence / "verified" / "release.json").exists())
441
+
442
+ def test_repacked_artifact_must_match_authenticated_digest(self):
443
+ def different_pack(root, output):
444
+ artifact = self.packed(root, output)
445
+ artifact["sha256"] = "f" * 64
446
+ return artifact
447
+ with mock.patch.object(validation, "pack", side_effect=different_pack):
448
+ with self.assertRaises(ValueError):
449
+ self.verify()
450
+ self.assertFalse((self.evidence / "verified" / "release.json").exists())
451
+
452
+ def test_missing_trusted_actor_fails_before_external_requests(self):
453
+ with self.assertRaises(ValueError):
454
+ validation.verify(self.root, self.REPOSITORY, "", self.evidence / "verified")
455
+ self.api.assert_not_called()
456
+
457
+
458
+ class ValidatedPublicationTests(RepositoryFixture, unittest.TestCase):
459
+ VERSION = "2.0.66"
460
+
461
+ def setUp(self):
462
+ super().setUp()
463
+ (self.root / "package.json").write_text(json.dumps(
464
+ {"name": "claude-multiacc", "version": self.VERSION}))
465
+ self.commit()
466
+ self.identity = validation.git_identity(self.root)
467
+ artifacts = tempfile.TemporaryDirectory(prefix="multiacc-publication-fixture-")
468
+ self.addCleanup(artifacts.cleanup)
469
+ self.output = Path(artifacts.name)
470
+ self.tarball = self.output / f"claude-multiacc-{self.VERSION}.tgz"
471
+ self.tarball.write_bytes(b"exact validated release package")
472
+ self.record = {**self.identity, "version": self.VERSION,
473
+ "artifact": {"filename": self.tarball.name,
474
+ "sha256": validation.sha256(self.tarball),
475
+ "integrity": "sha512-" + base64.b64encode(
476
+ hashlib.sha512(self.tarball.read_bytes()).digest()).decode()}}
477
+ self.release = self.output / "release.json"
478
+ self.release.write_text(json.dumps(self.record))
479
+ self.commands = []
480
+ self.did_publish = False
481
+ self.publish_error = False
482
+ self.registry_existing = None
483
+ self.registry_output = None
484
+ self.registry_latest = "2.0.65"
485
+ self.readback_integrity = self.record["artifact"]["integrity"]
486
+ self.readback_results = []
487
+ self.readback_calls = 0
488
+ self.process_timeouts = []
489
+ self.clock = datetime(2026, 10, 25, 12, 0, tzinfo=timezone.utc).timestamp()
490
+ self.sleeps = []
491
+ self.remote_tag = ""
492
+ self.run_age = 20
493
+
494
+ def response(self, argv):
495
+ self.commands.append(list(argv))
496
+ if argv == ["git", "rev-parse", "HEAD"]:
497
+ return 0, self.identity["head"], ""
498
+ if argv[:2] == ["gh", "api"]:
499
+ created = datetime.now(timezone.utc) - timedelta(seconds=self.run_age)
500
+ return 0, json.dumps({"created_at": created.isoformat()}), ""
501
+ if argv[:3] == ["git", "ls-remote", "--tags"]:
502
+ return 0, self.remote_tag, ""
503
+ if argv[:2] == ["npm", "view"]:
504
+ if argv[2] == "claude-multiacc":
505
+ return 0, self.registry_latest, ""
506
+ self.assertEqual(argv[2], f"claude-multiacc@{self.VERSION}")
507
+ integrity = self.readback_integrity if self.did_publish else self.registry_existing
508
+ if integrity is None:
509
+ return 1, "", "npm error code E404"
510
+ return 0, (json.dumps(integrity) if self.registry_output is None
511
+ else self.registry_output), ""
512
+ if argv[:2] == ["npm", "publish"]:
513
+ self.did_publish = True
514
+ return (1, "", "publication response lost") if self.publish_error else (0, "published fixture", "")
515
+ if argv[:2] in (["git", "config"], ["git", "tag"], ["git", "push"]):
516
+ return 0, "", ""
517
+ raise AssertionError(f"unexpected publication command: {argv}")
518
+
519
+ def execute(self, argv, root=None, timeout=30):
520
+ code, out, err = self.response(argv)
521
+ if code:
522
+ raise ValueError(err)
523
+ return out
524
+
525
+ def run_process(self, argv, **kwargs):
526
+ self.process_timeouts.append((list(argv), kwargs.get("timeout"), self.clock))
527
+ if argv[:3] == ["npm", "view", f"claude-multiacc@{self.VERSION}"] and self.did_publish:
528
+ self.readback_calls += 1
529
+ self.assertLess(self.readback_calls, 200, "registry polling must make bounded progress")
530
+ if self.readback_results:
531
+ result = self.readback_results.pop(0)
532
+ if isinstance(result, Exception):
533
+ self.commands.append(list(argv))
534
+ if isinstance(result, subprocess.TimeoutExpired):
535
+ self.clock += kwargs["timeout"]
536
+ raise result
537
+ self.readback_integrity = result
538
+ code, out, err = self.response(argv)
539
+ if kwargs.get("check") and code:
540
+ raise subprocess.CalledProcessError(code, argv, output=out, stderr=err)
541
+ return subprocess.CompletedProcess(argv, code, stdout=out, stderr=err)
542
+
543
+ def publish(self):
544
+ created = datetime.fromtimestamp(self.clock - self.run_age, timezone.utc)
545
+ self.deadline = created.timestamp() + 115
546
+ def sleep(seconds):
547
+ self.assertGreater(seconds, 0)
548
+ self.assertLessEqual(seconds, 1)
549
+ self.sleeps.append(seconds)
550
+ self.clock += seconds
551
+ with mock.patch.object(publisher, "execute", side_effect=self.execute), \
552
+ mock.patch.object(publisher.subprocess, "run", side_effect=self.run_process), \
553
+ mock.patch.object(publisher.time, "time", side_effect=lambda: self.clock), \
554
+ mock.patch.object(publisher.time, "sleep", side_effect=sleep):
555
+ return publisher.publish_release(self.record, self.root, self.output, created)
556
+
557
+ def assert_not_published_or_tagged(self):
558
+ self.assertFalse(self.did_publish)
559
+ self.assertFalse(any(command[:2] in (["git", "tag"], ["git", "push"])
560
+ for command in self.commands), self.commands)
561
+
562
+ def test_only_exact_verified_tarball_is_published_then_tagged(self):
563
+ result = self.publish()
564
+ publications = [command for command in self.commands if command[:2] == ["npm", "publish"]]
565
+ self.assertEqual(len(publications), 1)
566
+ self.assertEqual(publications[0][2], str(self.tarball))
567
+ self.assertIn("--ignore-scripts", publications[0])
568
+ self.assertTrue(any(command[:2] == ["git", "push"] for command in self.commands))
569
+ self.assertEqual(result["integrity"], self.record["artifact"]["integrity"])
570
+
571
+ def test_artifact_tamper_prevents_any_external_action(self):
572
+ self.tarball.write_bytes(b"unvalidated bytes")
573
+ with self.assertRaises(ValueError):
574
+ self.publish()
575
+ self.assertEqual(self.commands, [])
576
+
577
+ def test_recorded_integrity_must_match_the_actual_tarball(self):
578
+ self.record["artifact"]["integrity"] = "sha512-forged"
579
+ with self.assertRaises(ValueError):
580
+ self.publish()
581
+ self.assertEqual(self.commands, [])
582
+
583
+ def test_changed_head_prevents_publication(self):
584
+ self.record["head"] = "c" * 40
585
+ self.release.write_text(json.dumps(self.record))
586
+ with self.assertRaises(ValueError):
587
+ self.publish()
588
+ self.assert_not_published_or_tagged()
589
+
590
+ def test_exhausted_total_run_budget_prevents_publication(self):
591
+ self.run_age = 130
592
+ with self.assertRaises(ValueError):
593
+ self.publish()
594
+ self.assert_not_published_or_tagged()
595
+
596
+ def test_newer_registry_version_blocks_an_unpublished_old_candidate(self):
597
+ self.registry_latest = "2.0.99"
598
+ with self.assertRaises(ValueError):
599
+ self.publish()
600
+ self.assert_not_published_or_tagged()
601
+
602
+ def test_conflicting_remote_tag_is_checked_before_publishing(self):
603
+ self.remote_tag = f"{'f' * 40}\trefs/tags/v{self.VERSION}\n"
604
+ with self.assertRaises(ValueError):
605
+ self.publish()
606
+ self.assert_not_published_or_tagged()
607
+
608
+ def test_conflicting_existing_registry_bytes_are_never_republished(self):
609
+ self.registry_existing = "sha512-another-artifact"
610
+ with self.assertRaises(ValueError):
611
+ self.publish()
612
+ self.assert_not_published_or_tagged()
613
+
614
+ def test_identical_existing_release_repairs_tag_without_republishing(self):
615
+ self.registry_existing = self.record["artifact"]["integrity"]
616
+ self.registry_latest = "2.0.99"
617
+ self.publish()
618
+ self.assertFalse(self.did_publish)
619
+ self.assertTrue(any(command[:2] == ["git", "push"] for command in self.commands))
620
+
621
+ def test_npm12_existing_singleton_integrity_repairs_tag_without_republishing(self):
622
+ self.registry_existing = [self.record["artifact"]["integrity"]]
623
+ self.registry_latest = "2.0.99"
624
+ result = self.publish()
625
+ self.assertEqual(result["integrity"], self.record["artifact"]["integrity"])
626
+ self.assertFalse(self.did_publish)
627
+ self.assertTrue(any(command[:2] == ["git", "push"] for command in self.commands))
628
+
629
+ def test_npm12_singleton_readback_confirms_exact_new_publication(self):
630
+ self.readback_integrity = [self.record["artifact"]["integrity"]]
631
+ result = self.publish()
632
+ self.assertEqual(result["integrity"], self.record["artifact"]["integrity"])
633
+ self.assertEqual(sum(command[:2] == ["npm", "publish"] for command in self.commands), 1)
634
+ self.assertTrue(any(command[:2] == ["git", "push"] for command in self.commands))
635
+
636
+ def test_npm12_singleton_recovers_a_lost_publish_response(self):
637
+ self.publish_error = True
638
+ self.readback_integrity = [self.record["artifact"]["integrity"]]
639
+ result = self.publish()
640
+ self.assertEqual(result["integrity"], self.record["artifact"]["integrity"])
641
+ self.assertEqual(sum(command[:2] == ["npm", "publish"] for command in self.commands), 1)
642
+ self.assertTrue(any(command[:2] == ["git", "push"] for command in self.commands))
643
+
644
+ def test_npm12_conflicting_singleton_integrity_is_never_republished(self):
645
+ self.registry_existing = ["sha512-another-artifact"]
646
+ with self.assertRaisesRegex(ValueError, "different bytes"):
647
+ self.publish()
648
+ self.assert_not_published_or_tagged()
649
+
650
+ def test_malformed_or_ambiguous_integrity_prevents_publication(self):
651
+ integrity = self.record["artifact"]["integrity"]
652
+ self.registry_existing = integrity
653
+ for value in ([], [integrity, integrity], [integrity, "sha512-other"],
654
+ [[integrity]], [None], [42], {"integrity": integrity},
655
+ None, True, 42, "", [""]):
656
+ with self.subTest(value=value):
657
+ self.commands.clear()
658
+ self.registry_output = json.dumps(value)
659
+ with self.assertRaisesRegex(ValueError, "ambiguous or malformed"):
660
+ self.publish()
661
+ self.assert_not_published_or_tagged()
662
+ for output in ("not-json", '["unterminated"', json.dumps(integrity) + " trailing"):
663
+ with self.subTest(output=output):
664
+ self.commands.clear()
665
+ self.registry_output = output
666
+ with self.assertRaises(ValueError):
667
+ self.publish()
668
+ self.assert_not_published_or_tagged()
669
+
670
+ def test_ambiguous_readback_never_creates_a_tag(self):
671
+ integrity = self.record["artifact"]["integrity"]
672
+ self.readback_integrity = [integrity, integrity]
673
+ with self.assertRaisesRegex(ValueError, "ambiguous or malformed"):
674
+ self.publish()
675
+ self.assertTrue(self.did_publish)
676
+ self.assertFalse(any(command[:2] in (["git", "tag"], ["git", "push"])
677
+ for command in self.commands), self.commands)
678
+
679
+ def test_npm12_mismatched_readback_never_creates_a_tag(self):
680
+ self.readback_integrity = ["sha512-another-artifact"]
681
+ with self.assertRaisesRegex(ValueError, "did not confirm"):
682
+ self.publish()
683
+ self.assertTrue(self.did_publish)
684
+ self.assertFalse(any(command[:2] in (["git", "tag"], ["git", "push"])
685
+ for command in self.commands), self.commands)
686
+
687
+ def test_lost_publish_response_recovers_only_after_exact_registry_readback(self):
688
+ self.publish_error = True
689
+ result = self.publish()
690
+ self.assertEqual(result["integrity"], self.record["artifact"]["integrity"])
691
+ self.assertEqual(sum(command[:2] == ["npm", "publish"] for command in self.commands), 1)
692
+ self.assertTrue(any(command[:2] == ["git", "push"] for command in self.commands))
693
+
694
+ def test_existing_matching_annotated_tag_needs_no_write(self):
695
+ self.registry_existing = self.record["artifact"]["integrity"]
696
+ self.remote_tag = (f"{'f' * 40}\trefs/tags/v{self.VERSION}\n"
697
+ f"{self.identity['head']}\trefs/tags/v{self.VERSION}^{{}}\n")
698
+ self.publish()
699
+ self.assert_not_published_or_tagged()
700
+
701
+ def test_registry_readback_mismatch_prevents_tagging(self):
702
+ self.readback_integrity = "sha512-not-what-was-validated"
703
+ with self.assertRaises(ValueError):
704
+ self.publish()
705
+ self.assertTrue(self.did_publish)
706
+ self.assertFalse(any(command[:2] in (["git", "tag"], ["git", "push"])
707
+ for command in self.commands), self.commands)
708
+
709
+ def test_registry_confirmation_can_arrive_after_more_than_five_misses(self):
710
+ self.readback_results = [None] * 7 + [self.record["artifact"]["integrity"]]
711
+ result = self.publish()
712
+ self.assertEqual(result["integrity"], self.record["artifact"]["integrity"])
713
+ self.assertEqual(self.readback_calls, 8)
714
+ self.assertEqual(sum(command[:2] == ["npm", "publish"] for command in self.commands), 1)
715
+ self.assertTrue(any(command[:2] == ["git", "push"] for command in self.commands))
716
+ self.assertLessEqual(self.clock, self.deadline - 18)
717
+
718
+ def test_missing_confirmation_exhausts_only_readback_budget_without_tagging(self):
719
+ self.readback_integrity = None
720
+ with self.assertRaises(publisher.PublicationPending):
721
+ self.publish()
722
+ self.assertGreater(self.readback_calls, 5)
723
+ self.assertGreater(len(self.sleeps), 5)
724
+ self.assertEqual(self.clock, self.deadline - 18)
725
+ self.assertTrue(self.did_publish)
726
+ self.assertFalse(any(command[:2] in (["git", "tag"], ["git", "push"])
727
+ for command in self.commands), self.commands)
728
+
729
+ def test_timed_out_registry_readback_can_recover_with_exact_bytes(self):
730
+ self.readback_results = [subprocess.TimeoutExpired("npm view", 8),
731
+ self.record["artifact"]["integrity"]]
732
+ result = self.publish()
733
+ self.assertEqual(result["integrity"], self.record["artifact"]["integrity"])
734
+ self.assertEqual(self.readback_calls, 2)
735
+ self.assertTrue(any(command[:2] == ["git", "push"] for command in self.commands))
736
+ self.assertLessEqual(self.clock, self.deadline - 18)
737
+
738
+ def test_timeout_near_deadline_cannot_consume_tag_reserve(self):
739
+ self.run_age = 89.5
740
+ self.readback_results = [subprocess.TimeoutExpired("npm view", 8)]
741
+ with self.assertRaises(publisher.PublicationPending):
742
+ self.publish()
743
+ self.assertEqual(self.readback_calls, 1)
744
+ readback = [entry for entry in self.process_timeouts
745
+ if entry[0][:3] == ["npm", "view", f"claude-multiacc@{self.VERSION}"]][-1]
746
+ self.assertEqual(readback[1], 7.5)
747
+ self.assertEqual(self.clock, self.deadline - 18)
748
+ self.assertFalse(any(command[:2] in (["git", "tag"], ["git", "push"])
749
+ for command in self.commands), self.commands)
750
+
751
+
752
+ class PackedArtifactTests(unittest.TestCase):
753
+ VERSION = "2.0.66"
754
+
755
+ def setUp(self):
756
+ temporary = tempfile.TemporaryDirectory(prefix="multiacc-pack-fixture-")
757
+ self.addCleanup(temporary.cleanup)
758
+ self.root = Path(temporary.name) / "source"
759
+ self.root.mkdir()
760
+ self.output = Path(temporary.name) / "output"
761
+ self.output.mkdir()
762
+ self.package = {"name": "claude-multiacc", "version": self.VERSION}
763
+ (self.root / "package.json").write_text(json.dumps(self.package))
764
+ self.filename = f"claude-multiacc-{self.VERSION}.tgz"
765
+ self.manifests = [self.package]
766
+ self.npm_output = ""
767
+
768
+ def npm_pack(self, argv, root=None, timeout=30):
769
+ self.assertEqual(argv[:4], ["npm", "pack", "--ignore-scripts", "--pack-destination"])
770
+ self.assertEqual(root, self.root)
771
+ destination = Path(argv[4])
772
+ self.assertNotEqual(destination, self.output)
773
+ self.assertEqual(list(destination.iterdir()), [])
774
+ for index, manifest in enumerate(self.manifests):
775
+ filename = self.filename if index == 0 else f"unexpected-{index}.tgz"
776
+ with tarfile.open(destination / filename, "w:gz") as archive:
777
+ if manifest is not None:
778
+ data = json.dumps(manifest).encode()
779
+ info = tarfile.TarInfo("package/package.json")
780
+ info.size = len(data)
781
+ archive.addfile(info, io.BytesIO(data))
782
+ return self.npm_output
783
+
784
+ def pack(self):
785
+ with mock.patch.object(validation, "execute", side_effect=self.npm_pack):
786
+ return validation.pack(self.root, self.output)
787
+
788
+ def test_verified_archive_is_independent_of_npm_output_envelope(self):
789
+ stale = self.output / "unrelated-previous-package.tgz"
790
+ stale.write_bytes(b"unrelated earlier artifact")
791
+ for output in ("", "npm notice package contents\nclaude-multiacc-2.0.66.tgz",
792
+ '[{"filename":"wrong.tgz","integrity":"forged"}]',
793
+ '{"packages":[{"shasum":"forged"}]}'):
794
+ with self.subTest(output=output):
795
+ self.npm_output = output
796
+ artifact = self.pack()
797
+ packed = (self.output / self.filename).read_bytes()
798
+ self.assertEqual(artifact, {
799
+ "filename": self.filename,
800
+ "sha256": hashlib.sha256(packed).hexdigest(),
801
+ "integrity": "sha512-" + base64.b64encode(hashlib.sha512(packed).digest()).decode(),
802
+ })
803
+ self.assertEqual(stale.read_bytes(), b"unrelated earlier artifact")
804
+
805
+ def test_missing_new_archive_cannot_reuse_an_old_artifact(self):
806
+ (self.output / self.filename).write_bytes(b"stale existing artifact")
807
+ self.manifests = []
808
+ with self.assertRaisesRegex(ValueError, "one package archive"):
809
+ self.pack()
810
+
811
+ def test_multiple_archives_are_rejected(self):
812
+ self.manifests = [self.package, self.package]
813
+ with self.assertRaisesRegex(ValueError, "one package archive"):
814
+ self.pack()
815
+ self.assertEqual(list(self.output.glob("*.tgz")), [])
816
+
817
+ def test_archive_without_package_manifest_is_rejected(self):
818
+ self.manifests = [None]
819
+ with self.assertRaises((KeyError, ValueError)):
820
+ self.pack()
821
+ self.assertEqual(list(self.output.glob("*.tgz")), [])
822
+
823
+ def test_wrong_packed_package_name_or_version_is_rejected(self):
824
+ for changes in ({"name": "another-package"}, {"version": "2.0.65"}):
825
+ with self.subTest(changes=changes):
826
+ self.manifests = [{**self.package, **changes}]
827
+ with self.assertRaisesRegex(ValueError, "identity differs"):
828
+ self.pack()
829
+ self.assertEqual(list(self.output.glob("*.tgz")), [])
830
+
831
+
832
+ class PublicationAttemptTests(unittest.TestCase):
833
+ CREATED = "2026-10-24T10:00:00Z"
834
+ STARTED = "2026-10-25T12:00:00Z"
835
+
836
+ def setUp(self):
837
+ temporary = tempfile.TemporaryDirectory(prefix="multiacc-attempt-fixture-")
838
+ self.addCleanup(temporary.cleanup)
839
+ self.output = Path(temporary.name)
840
+ self.release = self.output / "release.json"
841
+ self.summary = self.output / "summary.md"
842
+ self.record = {"tree": "a" * 40, "artifact": {"sha256": "b" * 64}}
843
+ self.release.write_text(json.dumps(self.record))
844
+ self.environment = {"GITHUB_ACTIONS": "true", "GITHUB_REPOSITORY": "owner/claude-multiacc",
845
+ "GITHUB_RUN_ID": "123", "GITHUB_STEP_SUMMARY": str(self.summary)}
846
+ self.result = {"name": "claude-multiacc", "version": "2.0.66", "tag": "v2.0.66"}
847
+
848
+ def main(self, attempt=None, error=None):
849
+ environment = dict(self.environment)
850
+ if attempt is not None:
851
+ environment["GITHUB_RUN_ATTEMPT"] = str(attempt)
852
+ with mock.patch.dict(os.environ, environment, clear=True), \
853
+ mock.patch.object(sys, "argv", ["publish_validated.py", "--release", str(self.release)]), \
854
+ mock.patch.object(publisher, "execute", return_value=json.dumps({
855
+ "created_at": self.CREATED, "run_started_at": self.STARTED})) as api, \
856
+ mock.patch.object(publisher, "publish_release", return_value=self.result,
857
+ side_effect=error) as publish:
858
+ try:
859
+ publisher.main()
860
+ finally:
861
+ api.assert_called_once_with([
862
+ "gh", "api", f"repos/owner/claude-multiacc/actions/runs/123/attempts/{attempt or 1}"])
863
+ publish.assert_called_once()
864
+ self.assertEqual(publish.call_args.args[0], self.record)
865
+ self.assertEqual(publish.call_args.args[2], self.output)
866
+ self.started = publish.call_args.args[3]
867
+
868
+ def test_first_attempt_uses_original_creation_time_including_queue_delay(self):
869
+ self.main()
870
+ self.assertEqual(self.started, datetime.fromisoformat(self.CREATED.replace("Z", "+00:00")))
871
+ summary = self.summary.read_text()
872
+ self.assertIn(self.CREATED, summary)
873
+ self.assertIn("attempt 1 started", summary)
874
+ self.assertIn(self.STARTED, summary)
875
+
876
+ def test_rerun_uses_attempt_start_instead_of_old_original_creation(self):
877
+ self.main(attempt=2)
878
+ self.assertEqual(self.started, datetime.fromisoformat(self.STARTED.replace("Z", "+00:00")))
879
+ summary = self.summary.read_text()
880
+ self.assertIn(self.CREATED, summary)
881
+ self.assertIn("attempt 2 started", summary)
882
+ self.assertIn(self.STARTED, summary)
883
+
884
+ def test_pending_registry_confirmation_does_not_claim_deployment(self):
885
+ with self.assertRaises(publisher.PublicationPending):
886
+ self.main(attempt=2, error=publisher.PublicationPending("not confirmed"))
887
+ summary = self.summary.read_text()
888
+ self.assertIn("pending registry confirmation", summary)
889
+ self.assertIn("not deployed", summary)
890
+ self.assertNotIn("Published", summary)
891
+
892
+
893
+ if __name__ == "__main__":
894
+ unittest.main()