claude-dev-env 8.8.0 → 8.10.0

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 (39) hide show
  1. package/.agents/skills/privacy-hygiene/SKILL.md +1 -0
  2. package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
  3. package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
  4. package/bin/install-constants.mjs +5 -0
  5. package/bin/install.cursor-rules.test.mjs +6 -0
  6. package/bin/install.mjs +2 -2
  7. package/docs/second-claude-account.md +8 -1
  8. package/hooks/hooks.json +37 -0
  9. package/hooks/hooks_constants/skill_loaded_reminder_constants.py +70 -0
  10. package/hooks/session/skill_loaded_reminder.py +214 -0
  11. package/hooks/session/test_skill_loaded_reminder.py +253 -0
  12. package/package.json +1 -1
  13. package/rules/durable-post-artifacts.md +26 -1
  14. package/rules/prefer-existing-tools.md +35 -0
  15. package/rules/review-closure-is-a-check.md +1 -1
  16. package/scripts/claude_account_choice.py +51 -6
  17. package/scripts/dev_env_scripts_constants/claude_account_constants.py +15 -0
  18. package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
  19. package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
  20. package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +25 -0
  21. package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
  22. package/scripts/durable_post_lint.py +47 -1
  23. package/scripts/durable_post_lint_config/config/constants.py +1 -0
  24. package/scripts/gh_artifact_upload.py +56 -3
  25. package/scripts/private_term_scan.py +135 -0
  26. package/scripts/private_terms.py +87 -0
  27. package/scripts/repository_checks/config/constants.py +3 -0
  28. package/scripts/repository_checks/runner.py +7 -0
  29. package/scripts/repository_checks/tracked_private_terms.py +57 -0
  30. package/scripts/test_claude_account_choice.py +28 -0
  31. package/scripts/test_durable_post_lint.py +90 -0
  32. package/scripts/tests/test_gh_artifact_upload.py +75 -0
  33. package/scripts/tests/test_match_exemptions.py +2 -2
  34. package/scripts/tests/test_private_term_scan.py +176 -0
  35. package/scripts/tests/test_private_terms.py +59 -0
  36. package/scripts/tests/test_repository_policy.py +2 -0
  37. package/scripts/tests/test_runner.py +20 -0
  38. package/scripts/tests/test_tracked_private_terms.py +71 -0
  39. package/scripts/tests/test_tracked_secrets.py +1 -1
@@ -0,0 +1,135 @@
1
+ """Fail a GitHub event whose text or commits name a private organization.
2
+
3
+ The workflow ``private-terms.yml`` runs this on pull request, issue, comment,
4
+ review, and release events. Each finding names where the text sits and its
5
+ line number, never the name itself, so the public log stays clean::
6
+
7
+ pull request body: Line 4 names a private organization. ...
8
+ commit 3f2a9c1b77de identity: Line 1 names a private organization. ...
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import subprocess
16
+ import sys
17
+ from collections.abc import Sequence
18
+ from pathlib import Path
19
+
20
+ from dev_env_scripts_constants.private_term_constants import (
21
+ ALL_EVENT_TEXT_FIELDS,
22
+ ALL_PRIVATE_TERM_DIGESTS,
23
+ COMMIT_FIELD_SEPARATOR,
24
+ COMMIT_FIELD_SPLIT_LIMIT,
25
+ COMMIT_IDENTITY_PART,
26
+ COMMIT_LABEL_TEMPLATE,
27
+ COMMIT_LOG_FORMAT,
28
+ COMMIT_MESSAGE_PART,
29
+ COMMIT_RECORD_SEPARATOR,
30
+ FINDING_LINE_TEMPLATE,
31
+ PRIVATE_TERM_MESSAGE_TEMPLATE,
32
+ PRIVATE_TERM_TEXT_ENCODING,
33
+ SHORT_SHA_LENGTH,
34
+ )
35
+ from private_terms import private_term_line_numbers
36
+
37
+
38
+ def _findings_for(label: str, text: str) -> list[str]:
39
+ return [
40
+ FINDING_LINE_TEMPLATE.format(
41
+ label=label,
42
+ message=PRIVATE_TERM_MESSAGE_TEMPLATE.format(line_number=each_line_number),
43
+ )
44
+ for each_line_number in private_term_line_numbers(
45
+ text, ALL_PRIVATE_TERM_DIGESTS
46
+ )
47
+ ]
48
+
49
+
50
+ def scan_event(event_path: Path) -> list[str]:
51
+ """Return findings for the texts a GitHub event payload carries.
52
+
53
+ Args:
54
+ event_path: Path of the event JSON the runner writes.
55
+
56
+ Returns:
57
+ One finding per line that names a private organization.
58
+ """
59
+ event = json.loads(event_path.read_text(encoding=PRIVATE_TERM_TEXT_ENCODING))
60
+ all_findings: list[str] = []
61
+ for each_object_key, each_field, each_label in ALL_EVENT_TEXT_FIELDS:
62
+ event_object = event.get(each_object_key)
63
+ if not isinstance(event_object, dict):
64
+ continue
65
+ text = event_object.get(each_field)
66
+ if isinstance(text, str):
67
+ all_findings.extend(_findings_for(each_label, text))
68
+ return all_findings
69
+
70
+
71
+ def _commit_findings(record: str) -> list[str]:
72
+ full_sha, identity, message = record.split(
73
+ COMMIT_FIELD_SEPARATOR, COMMIT_FIELD_SPLIT_LIMIT
74
+ )
75
+ short_sha = full_sha[:SHORT_SHA_LENGTH]
76
+ return [
77
+ *_findings_for(
78
+ COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_IDENTITY_PART),
79
+ identity,
80
+ ),
81
+ *_findings_for(
82
+ COMMIT_LABEL_TEMPLATE.format(short_sha=short_sha, part=COMMIT_MESSAGE_PART),
83
+ message,
84
+ ),
85
+ ]
86
+
87
+
88
+ def scan_commits(repository_root: Path, commit_range: str) -> list[str]:
89
+ """Return findings for commit messages and identities in a range, oldest first.
90
+
91
+ Args:
92
+ repository_root: Git checkout holding the range.
93
+ commit_range: A ``base..head`` revision range.
94
+
95
+ Returns:
96
+ One finding per commit identity or message line that names a private
97
+ organization.
98
+ """
99
+ log_text = subprocess.run(
100
+ ["git", "-C", str(repository_root), "log", "--reverse", COMMIT_LOG_FORMAT, commit_range],
101
+ check=True,
102
+ capture_output=True,
103
+ encoding=PRIVATE_TERM_TEXT_ENCODING,
104
+ ).stdout
105
+ return [
106
+ each_finding
107
+ for each_record in log_text.split(COMMIT_RECORD_SEPARATOR)
108
+ if each_record.strip()
109
+ for each_finding in _commit_findings(each_record.strip())
110
+ ]
111
+
112
+
113
+ def main(all_arguments: Sequence[str]) -> int:
114
+ """Print every finding and return 1 when any text names a private organization.
115
+
116
+ Args:
117
+ all_arguments: Command-line arguments after the script name.
118
+
119
+ Returns:
120
+ 0 for a clean event, 1 when any finding printed.
121
+ """
122
+ parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
123
+ parser.add_argument("--event-path", type=Path, required=True)
124
+ parser.add_argument("--commit-range")
125
+ arguments = parser.parse_args(list(all_arguments))
126
+ all_findings = scan_event(arguments.event_path)
127
+ if arguments.commit_range:
128
+ all_findings.extend(scan_commits(Path.cwd(), arguments.commit_range))
129
+ for each_finding in all_findings:
130
+ print(each_finding)
131
+ return 1 if all_findings else 0
132
+
133
+
134
+ if __name__ == "__main__":
135
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,87 @@
1
+ """Find private organization names in text without holding the names.
2
+
3
+ ``private_term_line_numbers`` reports where a name appears and never the
4
+ name, so a caller can print its result in a public log.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import bisect
10
+ import hashlib
11
+ from collections.abc import Collection
12
+
13
+ from dev_env_scripts_constants.private_term_constants import (
14
+ ALL_PRIVATE_TERM_DIGESTS,
15
+ PRIVATE_TERM_TEXT_ENCODING,
16
+ PrivateTermDigest,
17
+ )
18
+
19
+
20
+ def _normalized_text_and_line_starts(text: str) -> tuple[str, list[int]]:
21
+ all_normalized_lines: list[str] = []
22
+ all_line_starts: list[int] = []
23
+ offset = 0
24
+ for each_line in text.split("\n"):
25
+ normalized_line = "".join(
26
+ each_character
27
+ for each_character in each_line.lower()
28
+ if each_character.isascii() and each_character.isalnum()
29
+ )
30
+ all_line_starts.append(offset)
31
+ all_normalized_lines.append(normalized_line)
32
+ offset += len(normalized_line)
33
+ return "".join(all_normalized_lines), all_line_starts
34
+
35
+
36
+ def _matching_window_starts(
37
+ normalized_text: str, length: int, all_wanted_digests: set[str]
38
+ ) -> list[int]:
39
+ return [
40
+ each_start
41
+ for each_start in range(len(normalized_text) - length + 1)
42
+ if hashlib.sha256(
43
+ normalized_text[each_start : each_start + length].encode(
44
+ PRIVATE_TERM_TEXT_ENCODING
45
+ )
46
+ ).hexdigest()
47
+ in all_wanted_digests
48
+ ]
49
+
50
+
51
+ def private_term_line_numbers(
52
+ text: str,
53
+ all_term_digests: Collection[PrivateTermDigest] = ALL_PRIVATE_TERM_DIGESTS,
54
+ ) -> tuple[int, ...]:
55
+ """Return the sorted one-based line numbers where a private name starts.
56
+
57
+ Args:
58
+ text: Text to scan.
59
+ all_term_digests: Digests of the normalized names to find.
60
+
61
+ Returns:
62
+ Each line number once. A name that wraps onto the next line reports
63
+ the line where it starts.
64
+ """
65
+ normalized_text, all_line_starts = _normalized_text_and_line_starts(text)
66
+ all_found_line_numbers: set[int] = set()
67
+ for each_length in {each_digest.length for each_digest in all_term_digests}:
68
+ all_wanted_digests = {
69
+ each_digest.sha256
70
+ for each_digest in all_term_digests
71
+ if each_digest.length == each_length
72
+ }
73
+ all_found_line_numbers.update(
74
+ bisect.bisect_right(all_line_starts, each_start)
75
+ for each_start in _matching_window_starts(
76
+ normalized_text, each_length, all_wanted_digests
77
+ )
78
+ )
79
+ return tuple(sorted(all_found_line_numbers))
80
+
81
+
82
+ def names_private_term(
83
+ text: str,
84
+ all_term_digests: Collection[PrivateTermDigest] = ALL_PRIVATE_TERM_DIGESTS,
85
+ ) -> bool:
86
+ """Return whether the text names any private organization."""
87
+ return bool(private_term_line_numbers(text, all_term_digests))
@@ -12,6 +12,7 @@ CHECK_ID_ENV_VAR_DOCUMENTATION = "env-var-documentation"
12
12
  CHECK_ID_PACKAGE_INVENTORY = "package-inventory"
13
13
  CHECK_ID_PYTEST_TESTPATHS = "pytest-testpaths"
14
14
  CHECK_ID_TRACKED_PERSONAL_DATA = "tracked-secrets"
15
+ CHECK_ID_TRACKED_PRIVATE_TERMS = "tracked-private-terms"
15
16
 
16
17
  ALL_CHECK_IDS = (
17
18
  CHECK_ID_CLAUDE_MD_ORPHANS,
@@ -19,6 +20,7 @@ ALL_CHECK_IDS = (
19
20
  CHECK_ID_PACKAGE_INVENTORY,
20
21
  CHECK_ID_PYTEST_TESTPATHS,
21
22
  CHECK_ID_TRACKED_PERSONAL_DATA,
23
+ CHECK_ID_TRACKED_PRIVATE_TERMS,
22
24
  )
23
25
 
24
26
  SEVERITY_BREAKING = "breaking"
@@ -29,6 +31,7 @@ SEVERITY_BY_CHECK_ID: dict[str, str] = {
29
31
  CHECK_ID_PACKAGE_INVENTORY: SEVERITY_SMELL,
30
32
  CHECK_ID_PYTEST_TESTPATHS: SEVERITY_BREAKING,
31
33
  CHECK_ID_TRACKED_PERSONAL_DATA: SEVERITY_BREAKING,
34
+ CHECK_ID_TRACKED_PRIVATE_TERMS: SEVERITY_BREAKING,
32
35
  }
33
36
 
34
37
  SUCCESS_EXIT_CODE = 0
@@ -14,6 +14,9 @@ from repository_checks.env_var_documentation import (
14
14
  from repository_checks.models import RepositoryCheckReport, RepositoryFinding
15
15
  from repository_checks.package_inventory import collect_package_inventory_findings
16
16
  from repository_checks.pytest_testpaths import collect_pytest_testpath_findings
17
+ from repository_checks.tracked_private_terms import (
18
+ collect_tracked_private_term_findings,
19
+ )
17
20
  from repository_checks.tracked_secrets import collect_tracked_secret_findings
18
21
 
19
22
  RepositoryCollector = Callable[[Path, Sequence[str]], list[RepositoryFinding]]
@@ -90,4 +93,8 @@ def _all_collectors() -> tuple[tuple[str, RepositoryCollector], ...]:
90
93
  repository_constants.CHECK_ID_TRACKED_PERSONAL_DATA,
91
94
  collect_tracked_secret_findings,
92
95
  ),
96
+ (
97
+ repository_constants.CHECK_ID_TRACKED_PRIVATE_TERMS,
98
+ collect_tracked_private_term_findings,
99
+ ),
93
100
  )
@@ -0,0 +1,57 @@
1
+ """Tracked-file check for private organization names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ from dev_env_scripts_constants.private_term_constants import (
9
+ ALL_PRIVATE_TERM_DIGESTS,
10
+ PRIVATE_TERM_MESSAGE_TEMPLATE,
11
+ )
12
+ from policy_lint.config import constants as policy_constants
13
+ from private_terms import private_term_line_numbers
14
+
15
+ from repository_checks.config import constants as repository_constants
16
+ from repository_checks.models import RepositoryFinding
17
+
18
+
19
+ def _read_text_or_none(absolute_path: Path) -> str | None:
20
+ if not absolute_path.is_file():
21
+ return None
22
+ try:
23
+ return absolute_path.read_text(encoding=policy_constants.UTF8_ENCODING)
24
+ except UnicodeDecodeError:
25
+ return None
26
+
27
+
28
+ def collect_tracked_private_term_findings(
29
+ repository_root: Path, all_tracked_paths: Sequence[str]
30
+ ) -> list[RepositoryFinding]:
31
+ """Return one finding for each tracked line that names a private organization.
32
+
33
+ Args:
34
+ repository_root: Git repository root.
35
+ all_tracked_paths: Repository-relative tracked paths.
36
+
37
+ Returns:
38
+ Findings that carry the path and line number, never the name.
39
+ """
40
+ all_findings: list[RepositoryFinding] = []
41
+ for each_relative_path in all_tracked_paths:
42
+ if (content := _read_text_or_none(repository_root / each_relative_path)) is None:
43
+ continue
44
+ posix_relative_path = each_relative_path.replace(
45
+ repository_constants.WINDOWS_PATH_SEPARATOR, policy_constants.PATH_SEPARATOR
46
+ )
47
+ all_findings.extend(
48
+ RepositoryFinding(
49
+ repository_constants.CHECK_ID_TRACKED_PRIVATE_TERMS,
50
+ posix_relative_path,
51
+ PRIVATE_TERM_MESSAGE_TEMPLATE.format(line_number=each_line_number),
52
+ )
53
+ for each_line_number in private_term_line_numbers(
54
+ content, ALL_PRIVATE_TERM_DIGESTS
55
+ )
56
+ )
57
+ return all_findings
@@ -158,6 +158,32 @@ class TestDecisionPayload:
158
158
  }
159
159
 
160
160
 
161
+ class TestMetersPayload:
162
+ def should_name_no_meters_for_an_unread_account(self) -> None:
163
+ assert choice.meters_payload(None) is None
164
+
165
+ def should_carry_each_used_percent_and_reset_time(self) -> None:
166
+ read_meters = meters(
167
+ session_used=12.0,
168
+ weekly_used=34.0,
169
+ weekly_resets_in=timedelta(days=3),
170
+ session_resets_in=timedelta(hours=1),
171
+ )
172
+ assert choice.meters_payload(read_meters) == {
173
+ "session_used_percent": 12.0,
174
+ "session_resets_at": "2026-09-22T22:00:00+00:00",
175
+ "weekly_used_percent": 34.0,
176
+ "weekly_resets_at": "2026-09-25T21:00:00+00:00",
177
+ }
178
+
179
+ def should_name_no_reset_time_the_meter_left_out(self) -> None:
180
+ read_meters = meters(session_used=1.0, weekly_used=None, weekly_resets_in=None)
181
+ payload = choice.meters_payload(read_meters)
182
+ assert payload is not None
183
+ assert payload["weekly_used_percent"] is None
184
+ assert payload["weekly_resets_at"] is None
185
+
186
+
161
187
  class TestMain:
162
188
  def should_print_the_choice_and_the_config_directory_of_the_chosen_account(
163
189
  self,
@@ -190,6 +216,8 @@ class TestMain:
190
216
  assert exit_code == 0
191
217
  assert printed["account"] == choice.CHOICE_SECOND
192
218
  assert printed["config_dir"] == str(second_home)
219
+ assert printed["meters"]["main"] is None
220
+ assert printed["meters"]["second"]["weekly_used_percent"] == 10.0
193
221
 
194
222
  def should_print_no_config_directory_when_the_job_waits(
195
223
  self,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import hashlib
5
6
  import subprocess
6
7
  import sys
7
8
  from pathlib import Path
@@ -13,6 +14,7 @@ if str(_SCRIPTS_DIRECTORY) not in sys.path:
13
14
  sys.path.insert(0, str(_SCRIPTS_DIRECTORY))
14
15
 
15
16
  import durable_post_lint
17
+ from dev_env_scripts_constants.private_term_constants import PrivateTermDigest
16
18
 
17
19
  SCRIPT_PATH = _SCRIPTS_DIRECTORY / "durable_post_lint.py"
18
20
  VALID_PR_BODY = """## Why
@@ -506,3 +508,91 @@ def test_a_release_body_carrying_a_contrast_stays_accepted() -> None:
506
508
  )
507
509
 
508
510
  assert all_findings == ()
511
+
512
+
513
+ _PRIVATE_FIXTURE_TERM = "acmewidget"
514
+ _ALL_PRIVATE_FIXTURE_DIGESTS = frozenset(
515
+ {
516
+ PrivateTermDigest(
517
+ length=len(_PRIVATE_FIXTURE_TERM),
518
+ sha256=hashlib.sha256(_PRIVATE_FIXTURE_TERM.encode("utf-8")).hexdigest(),
519
+ )
520
+ }
521
+ )
522
+
523
+
524
+ @pytest.fixture
525
+ def private_fixture_terms(monkeypatch: pytest.MonkeyPatch) -> None:
526
+ monkeypatch.setattr(
527
+ durable_post_lint, "ALL_PRIVATE_TERM_DIGESTS", _ALL_PRIVATE_FIXTURE_DIGESTS
528
+ )
529
+
530
+
531
+ @pytest.mark.usefixtures("private_fixture_terms")
532
+ def test_a_body_naming_a_private_organization_reports_its_line() -> None:
533
+ all_findings = durable_post_lint.lint_durable_post(
534
+ action="pr-comment",
535
+ title=None,
536
+ body_text="Fixed.\nSee Acme-Widgets-Inc/tools#12.\n",
537
+ )
538
+
539
+ assert [each_finding.code for each_finding in all_findings] == ["private-term"]
540
+ assert "Line 2" in all_findings[0].message
541
+ assert "Acme" not in all_findings[0].message
542
+
543
+
544
+ @pytest.mark.usefixtures("private_fixture_terms")
545
+ def test_a_title_naming_a_private_organization_reports() -> None:
546
+ all_findings = durable_post_lint.lint_durable_post(
547
+ action="pr-create",
548
+ title="fix: handle the acme widgets upload",
549
+ body_text=VALID_PR_BODY,
550
+ )
551
+
552
+ assert [each_finding.code for each_finding in all_findings] == ["private-term"]
553
+
554
+
555
+ @pytest.mark.usefixtures("private_fixture_terms")
556
+ def test_a_post_inside_the_private_organization_may_name_it() -> None:
557
+ all_findings = durable_post_lint.lint_durable_post(
558
+ action="pr-comment",
559
+ title=None,
560
+ body_text="See Acme-Widgets-Inc/tools#12.\n",
561
+ repository="Acme-Widgets-Inc/tools",
562
+ )
563
+
564
+ assert all_findings == ()
565
+
566
+
567
+ @pytest.mark.usefixtures("private_fixture_terms")
568
+ def test_a_post_to_a_public_repository_may_not_name_it() -> None:
569
+ all_findings = durable_post_lint.lint_durable_post(
570
+ action="pr-comment",
571
+ title=None,
572
+ body_text="See Acme-Widgets-Inc/tools#12.\n",
573
+ repository="pat/public-tools",
574
+ )
575
+
576
+ assert [each_finding.code for each_finding in all_findings] == ["private-term"]
577
+
578
+
579
+ def test_cli_accepts_a_repository(tmp_path: Path) -> None:
580
+ body_file = tmp_path / "body.md"
581
+ body_file.write_text("Comment body.\n", encoding="utf-8")
582
+ completed_process = subprocess.run(
583
+ [
584
+ sys.executable,
585
+ str(SCRIPT_PATH),
586
+ "--action",
587
+ "pr-comment",
588
+ "--repository",
589
+ "pat/public-tools",
590
+ "--body-file",
591
+ str(body_file),
592
+ ],
593
+ check=False,
594
+ capture_output=True,
595
+ text=True,
596
+ )
597
+ assert completed_process.returncode == 0
598
+ assert completed_process.stderr == ""
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import shutil
6
7
  import subprocess
7
8
  import sys
8
9
  import types
@@ -26,6 +27,15 @@ _DEFAULT_READBACK_ASSET = {
26
27
  }
27
28
 
28
29
 
30
+ _REAL_WHICH = shutil.which
31
+ _REAL_RUN = subprocess.run
32
+
33
+
34
+ @pytest.fixture(autouse=True)
35
+ def oxipng_on_path(monkeypatch: pytest.MonkeyPatch) -> None:
36
+ monkeypatch.setattr(shutil, "which", lambda _name: "oxipng")
37
+
38
+
29
39
  def _make_gh_stub(
30
40
  recorded_calls: list[list[str]],
31
41
  view_return_code: int,
@@ -203,3 +213,68 @@ def test_upload_artifact_missing_file_raises(monkeypatch: pytest.MonkeyPatch) ->
203
213
  monkeypatch.setattr(subprocess, "run", _make_gh_stub([], view_return_code=0))
204
214
  with pytest.raises(mod.ArtifactUploadError):
205
215
  mod.upload_artifact("does_not_exist_9f3a.png", "owner/repo")
216
+
217
+
218
+ def test_upload_artifact_sends_the_oxipng_shrunk_png(
219
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
220
+ ) -> None:
221
+ image_module = pytest.importorskip("PIL.Image")
222
+ if _REAL_WHICH("oxipng") is None:
223
+ pytest.skip("oxipng is not on PATH")
224
+ monkeypatch.setattr(shutil, "which", _REAL_WHICH)
225
+ source_file = tmp_path / "contact_sheet.png"
226
+ image_module.new("RGB", (64, 64), (200, 40, 40)).save(
227
+ source_file, format="PNG", compress_level=0
228
+ )
229
+ source_bytes = source_file.read_bytes()
230
+ all_uploaded_sizes: list[int] = []
231
+ gh_stub = _make_gh_stub([], view_return_code=0)
232
+
233
+ def gh_stub_beside_live_oxipng(
234
+ all_command_arguments: list[str], **keyword_arguments: object
235
+ ) -> object:
236
+ if all_command_arguments[0] != "gh":
237
+ return _REAL_RUN(all_command_arguments, **keyword_arguments)
238
+ if "upload" in all_command_arguments:
239
+ staged_path = Path(all_command_arguments[4])
240
+ all_uploaded_sizes.append(staged_path.stat().st_size)
241
+ with image_module.open(staged_path) as uploaded:
242
+ assert uploaded.convert("RGB").tobytes() == bytes((200, 40, 40)) * 4096
243
+ return gh_stub(all_command_arguments, **keyword_arguments)
244
+
245
+ monkeypatch.setattr(subprocess, "run", gh_stub_beside_live_oxipng)
246
+
247
+ mod.upload_artifact(str(source_file), "owner/repo")
248
+
249
+ assert all_uploaded_sizes and all_uploaded_sizes[0] < len(source_bytes)
250
+ assert source_file.read_bytes() == source_bytes
251
+
252
+
253
+ def test_upload_artifact_refuses_a_png_when_oxipng_is_missing(
254
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
255
+ ) -> None:
256
+ source_file = tmp_path / "out.png"
257
+ source_file.write_bytes(b"binary")
258
+ recorded_calls: list[list[str]] = []
259
+ monkeypatch.setattr(shutil, "which", lambda _name: None)
260
+ monkeypatch.setattr(subprocess, "run", _make_gh_stub(recorded_calls, view_return_code=0))
261
+
262
+ with pytest.raises(mod.ArtifactUploadError, match="oxipng"):
263
+ mod.upload_artifact(str(source_file), "owner/repo")
264
+
265
+ assert not any("upload" in call for call in recorded_calls)
266
+
267
+
268
+ def test_optimize_png_losslessly_leaves_other_formats_alone(
269
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
270
+ ) -> None:
271
+ source_file = tmp_path / "trace.zip"
272
+ source_file.write_bytes(b"PK archive")
273
+ recorded_calls: list[list[str]] = []
274
+ monkeypatch.setattr(shutil, "which", lambda _name: None)
275
+ monkeypatch.setattr(subprocess, "run", _make_gh_stub(recorded_calls, view_return_code=0))
276
+
277
+ mod.optimize_png_losslessly(source_file)
278
+
279
+ assert source_file.read_bytes() == b"PK archive"
280
+ assert recorded_calls == []
@@ -21,8 +21,8 @@ from repository_checks.tracked_secrets import collect_tracked_secret_findings
21
21
  CONTACT = "fixture-contact@company.io"
22
22
  CHANGED_CONTACT = "changed-contact@company.io"
23
23
  DIGEST = hashlib.sha256(CONTACT.encode()).hexdigest()
24
- RUNNER_ADDRESS = "10.83.21.14"
25
- CHANGED_RUNNER_ADDRESS = "10.83.21.15"
24
+ RUNNER_ADDRESS = "10.0.0.14"
25
+ CHANGED_RUNNER_ADDRESS = "10.0.0.15"
26
26
  RUNNER_ADDRESS_DIGEST = hashlib.sha256(RUNNER_ADDRESS.encode()).hexdigest()
27
27
 
28
28