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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.8.0",
3
+ "version": "8.10.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,8 @@
3
3
  ## When this applies
4
4
 
5
5
  Use this rule for a GitHub issue, pull request, comment, or review created
6
- through `gh` or a GitHub MCP post tool.
6
+ through `gh` or a GitHub MCP post tool. The image section also covers a PNG a
7
+ commit adds and a file a release upload sends.
7
8
 
8
9
  ## Rule
9
10
 
@@ -26,6 +27,24 @@ Handle text and binary content differently:
26
27
  under a `YYYYMMDD_HHMMSS_<name>` asset name, and prints a permanent download
27
28
  URL. Put that URL in the post.
28
29
 
30
+ ## Optimize every image before it reaches GitHub
31
+
32
+ The helper shrinks a PNG with [oxipng](https://github.com/oxipng/oxipng) before
33
+ it uploads, and prints the size before and after. Oxipng is lossless.
34
+ `--strip none` keeps every chunk, and `--nb --nc` keep the bit depth and the
35
+ color type, so a reader that checks the image mode sees the same file shape.
36
+
37
+ Any other route to GitHub runs the same pass first: a PNG a commit adds, and a
38
+ file sent with `gh release upload`.
39
+
40
+ ```
41
+ oxipng --opt 4 --strip none --nb --nc <file> [<file> ...]
42
+ ```
43
+
44
+ The `Binary optimization` check fails a pull request whose changed PNG files
45
+ still shrink under that pass. A fixture whose exact bytes a test pins takes
46
+ `binary-optimizer=keep` in `.gitattributes`, and the check skips it.
47
+
29
48
  ## Volatile paths that must not appear in a post body
30
49
 
31
50
  - A job scratch directory: `.claude-profile-a/jobs/`
@@ -46,5 +65,11 @@ server write. Pass the matching action and body file. Use `pr-create`,
46
65
  `pr-edit`, `pr-comment`, `pr-review`, `issue-create`, `issue-edit`,
47
66
  `issue-comment`, or `github-mcp-post`.
48
67
 
68
+ Pass `--repository <owner>/<name>` for the repository the post targets. A
69
+ post may name a private organization only inside a repository that
70
+ organization owns. The linter holds the names as digests, so it reports the
71
+ line that names one without printing the name. Describe the organization in
72
+ general terms and drop the link.
73
+
49
74
  The linter reads the body file and reports a volatile local path without
50
75
  printing the body. Fix the body and rerun the linter before posting.
@@ -0,0 +1,35 @@
1
+ # Prefer Existing Tools
2
+
3
+ **When this applies:** Before you build a tool, check, scanner, script, or library of your own.
4
+
5
+ ## Rule
6
+
7
+ Search in this order, and stop at the first fit:
8
+
9
+ 1. **Internal code.** Search the repository and its shared packages for a tool that already does the job. Reuse or extend it.
10
+ 2. **An established open-source option.** Search for a plugin, package, or tool that already does the job.
11
+ 3. **Your own code.** Build it only when steps 1 and 2 find nothing that fits.
12
+
13
+ Use an open-source option only when it meets all four conditions:
14
+
15
+ - It is secure: it has an active security process and no open critical advisory.
16
+ - It is well known in its field.
17
+ - It is widely used: many stars, downloads, or dependents.
18
+ - It is maintained: it had a release or commit in the last year.
19
+
20
+ Add only what the option lacks. Put custom rules in its own configuration format. Do not wrap it in a new layer of your own code.
21
+
22
+ ## Report the choice
23
+
24
+ Tell the user which tool you chose before the work is marked ready. Give its name, link, license, and one number that shows how widely it is used. When you build your own code, name what you searched and why each candidate did not fit.
25
+
26
+ ## Why
27
+
28
+ Code of your own is code you maintain alone. A trusted tool carries fixes and new patterns from its community, and its users find its defects first.
29
+
30
+ ## Sibling rules
31
+
32
+ | Rule | Role |
33
+ |---|---|
34
+ | [`explore-thoroughly.md`](explore-thoroughly.md) | Read what exists before you propose a change |
35
+ | [`verify-before-asking.md`](verify-before-asking.md) | Answer a question with a tool before you ask it |
@@ -33,7 +33,7 @@ The driving account is the one that opened the pull request. Where the agent com
33
33
 
34
34
  `.github/workflows/review-closure.yml` runs it here on a push to a pull request, on a submitted or dismissed review, and on a review comment. Each run reports on the pull request's head commit, so a finding posted after the last push still turns the check red.
35
35
 
36
- `Echo-Visuals-Inc/python-automation` runs the same command from its own workflow, against the revision of this package that its workflow pins.
36
+ A private repository that installs this package runs the same command from its own workflow, against the revision of this package that its workflow pins.
37
37
 
38
38
  ## Sibling rules
39
39
 
@@ -41,7 +41,12 @@ from dev_env_scripts_constants.claude_account_constants import (
41
41
  FULL_PERCENT,
42
42
  JSON_ACCOUNT_KEY,
43
43
  JSON_CONFIG_DIRECTORY_KEY,
44
+ JSON_METERS_KEY,
44
45
  JSON_REASON_KEY,
46
+ JSON_SESSION_RESETS_AT_KEY,
47
+ JSON_SESSION_USED_PERCENT_KEY,
48
+ JSON_WEEKLY_RESETS_AT_KEY,
49
+ JSON_WEEKLY_USED_PERCENT_KEY,
45
50
  MAIN_CLAUDE_HOME_DIRECTORY_NAME,
46
51
  MAIN_SESSION_USED_CEILING_PERCENT,
47
52
  MAIN_SPEND_WINDOW,
@@ -216,8 +221,39 @@ def decision_payload(
216
221
  }
217
222
 
218
223
 
224
+ def _iso_or_none(moment: datetime | None) -> str | None:
225
+ return moment.isoformat() if moment else None
226
+
227
+
228
+ def meters_payload(
229
+ account_meters: AccountUsageMeters | None,
230
+ ) -> dict[str, float | str | None] | None:
231
+ """Shape one account's meters as the JSON object a usage report reads.
232
+
233
+ ::
234
+
235
+ AccountUsageMeters(12.0, <reset>, 34.0, <reset>)
236
+ -> {"session_used_percent": 12.0, "session_resets_at": "2026-09-22T22:00:00+00:00",
237
+ "weekly_used_percent": 34.0, "weekly_resets_at": "2026-09-25T21:00:00+00:00"}
238
+
239
+ Args:
240
+ account_meters: The account's meters, or None when unread.
241
+
242
+ Returns:
243
+ Each used percent and reset time, or None for an unread account.
244
+ """
245
+ if account_meters is None:
246
+ return None
247
+ return {
248
+ JSON_SESSION_USED_PERCENT_KEY: account_meters.session_utilization,
249
+ JSON_SESSION_RESETS_AT_KEY: _iso_or_none(account_meters.session_resets_at),
250
+ JSON_WEEKLY_USED_PERCENT_KEY: account_meters.weekly_utilization,
251
+ JSON_WEEKLY_RESETS_AT_KEY: _iso_or_none(account_meters.weekly_resets_at),
252
+ }
253
+
254
+
219
255
  def main(all_command_arguments: list[str]) -> int:
220
- """Print the chosen account as JSON.
256
+ """Print the chosen account and both accounts' meters as JSON.
221
257
 
222
258
  Args:
223
259
  all_command_arguments: Command-line arguments after the program name.
@@ -230,15 +266,24 @@ def main(all_command_arguments: list[str]) -> int:
230
266
  CHOICE_MAIN: arguments.main_config_dir,
231
267
  CHOICE_SECOND: arguments.second_config_dir,
232
268
  }
269
+ main_meters = read_account_meters(arguments.main_config_dir / CREDENTIALS_FILE_NAME)
270
+ second_meters = read_account_meters(
271
+ arguments.second_config_dir / CREDENTIALS_FILE_NAME
272
+ )
233
273
  decision = choose_account(
234
- main_meters=read_account_meters(arguments.main_config_dir / CREDENTIALS_FILE_NAME),
235
- second_meters=read_account_meters(
236
- arguments.second_config_dir / CREDENTIALS_FILE_NAME
237
- ),
274
+ main_meters=main_meters,
275
+ second_meters=second_meters,
238
276
  now=datetime.now().astimezone(),
239
277
  )
240
278
  config_directory = config_directory_by_account.get(decision.account)
241
- print(json.dumps(decision_payload(decision, config_directory=config_directory)))
279
+ report = {
280
+ **decision_payload(decision, config_directory=config_directory),
281
+ JSON_METERS_KEY: {
282
+ CHOICE_MAIN: meters_payload(main_meters),
283
+ CHOICE_SECOND: meters_payload(second_meters),
284
+ },
285
+ }
286
+ print(json.dumps(report))
242
287
  return 0
243
288
 
244
289
 
@@ -153,6 +153,21 @@ JSON_CONFIG_DIRECTORY_KEY: str = "config_dir"
153
153
  JSON_REASON_KEY: str = "reason"
154
154
  """Picker JSON key carrying the plain-words reason for the choice."""
155
155
 
156
+ JSON_METERS_KEY: str = "meters"
157
+ """Picker JSON key carrying both accounts' meters, each None when unread."""
158
+
159
+ JSON_SESSION_USED_PERCENT_KEY: str = "session_used_percent"
160
+ """Meters JSON key for the percent of the 5-hour window used."""
161
+
162
+ JSON_SESSION_RESETS_AT_KEY: str = "session_resets_at"
163
+ """Meters JSON key for when the 5-hour window resets."""
164
+
165
+ JSON_WEEKLY_USED_PERCENT_KEY: str = "weekly_used_percent"
166
+ """Meters JSON key for the percent of the week used."""
167
+
168
+ JSON_WEEKLY_RESETS_AT_KEY: str = "weekly_resets_at"
169
+ """Meters JSON key for when the week resets."""
170
+
156
171
  JSON_LINKED_KEY: str = "linked"
157
172
  """Sync JSON key listing entries the run linked."""
158
173
 
@@ -41,3 +41,30 @@ NOTES_FILE_SUFFIX: str = ".md"
41
41
 
42
42
  UTF8_ENCODING: str = "utf-8"
43
43
  """Text encoding for subprocess output and temp files."""
44
+
45
+ PNG_SUFFIX: str = ".png"
46
+ """Suffix of a file the upload shrinks with oxipng before it sends."""
47
+
48
+ OXIPNG_BINARY_NAME: str = "oxipng"
49
+ """The oxipng executable name, looked up on PATH."""
50
+
51
+ ALL_OXIPNG_LOSSLESS_ARGUMENTS: tuple[str, ...] = (
52
+ "--opt",
53
+ "4",
54
+ "--strip",
55
+ "none",
56
+ "--nb",
57
+ "--nc",
58
+ "--quiet",
59
+ )
60
+ """Oxipng options: a slow lossless search that keeps every chunk, the bit
61
+ depth, and the color type."""
62
+
63
+ OXIPNG_MISSING_MESSAGE: str = (
64
+ "oxipng is not on PATH. Install the release binary from "
65
+ "https://github.com/oxipng/oxipng/releases, then upload again."
66
+ )
67
+ """Failure text when a PNG upload finds no oxipng to shrink it first."""
68
+
69
+ SIZE_REPORT_TEMPLATE: str = "{name}: {original_size} -> {optimized_size} bytes"
70
+ """Line naming an upload and its size before and after oxipng."""
@@ -0,0 +1,60 @@
1
+ """Digests of the private organization names this public repository never names.
2
+
3
+ The names stay out of the tree. Each entry holds the SHA-256 digest of one
4
+ name after normalization, which keeps only ASCII letters and digits in lower
5
+ case::
6
+
7
+ "Acme-Widgets, Inc." -> "acmewidgetsinc"
8
+ "pat@acmewidgets.example.com" -> "patacmewidgetsexamplecom"
9
+
10
+ A scan hashes every window of the normalized text whose length matches an
11
+ entry, so any case, spacing, or punctuation of a name matches. To add a name,
12
+ append the digest of its normalized form and its length.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PrivateTermDigest:
22
+ """The length and SHA-256 hex digest of one normalized private name."""
23
+
24
+ length: int
25
+ sha256: str
26
+
27
+
28
+ ALL_PRIVATE_TERM_DIGESTS: frozenset[PrivateTermDigest] = frozenset(
29
+ {
30
+ PrivateTermDigest(
31
+ length=10,
32
+ sha256="c2a70b3186691a74dece4ca824bec178bfaec83be119be28faedd14aa392233f",
33
+ ),
34
+ }
35
+ )
36
+ PRIVATE_TERM_TEXT_ENCODING = "utf-8"
37
+ PRIVATE_TERM_FINDING_CODE = "private-term"
38
+ PRIVATE_TERM_MESSAGE_TEMPLATE = (
39
+ "Line {line_number} names a private organization. "
40
+ "Describe it in general terms and drop any link to it."
41
+ )
42
+ ALL_EVENT_TEXT_FIELDS: tuple[tuple[str, str, str], ...] = (
43
+ ("pull_request", "title", "pull request title"),
44
+ ("pull_request", "body", "pull request body"),
45
+ ("issue", "title", "issue title"),
46
+ ("issue", "body", "issue body"),
47
+ ("comment", "body", "comment body"),
48
+ ("review", "body", "review body"),
49
+ ("release", "name", "release name"),
50
+ ("release", "body", "release body"),
51
+ )
52
+ FINDING_LINE_TEMPLATE = "{label}: {message}"
53
+ COMMIT_LABEL_TEMPLATE = "commit {short_sha} {part}"
54
+ COMMIT_IDENTITY_PART = "identity"
55
+ COMMIT_MESSAGE_PART = "message"
56
+ SHORT_SHA_LENGTH = 12
57
+ COMMIT_FIELD_SEPARATOR = "\x1f"
58
+ COMMIT_RECORD_SEPARATOR = "\x1e"
59
+ COMMIT_FIELD_SPLIT_LIMIT = 2
60
+ COMMIT_LOG_FORMAT = "--format=%H%x1f%an <%ae> %cn <%ce>%x1f%B%x1e"
@@ -3,6 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dev_env_scripts_constants.claude_account_constants import (
6
+ JSON_ACCOUNT_KEY,
7
+ JSON_CONFIG_DIRECTORY_KEY,
8
+ JSON_METERS_KEY,
9
+ JSON_REASON_KEY,
10
+ JSON_SESSION_RESETS_AT_KEY,
11
+ JSON_SESSION_USED_PERCENT_KEY,
12
+ JSON_WEEKLY_RESETS_AT_KEY,
13
+ JSON_WEEKLY_USED_PERCENT_KEY,
6
14
  LAUNCHER_TEXT_TEMPLATE,
7
15
  MAIN_SESSION_USED_CEILING_PERCENT,
8
16
  MAIN_WEEKLY_USED_CEILING_PERCENT,
@@ -19,3 +27,20 @@ def test_main_ceilings_sit_below_the_second_account_ceilings() -> None:
19
27
  def test_launcher_names_the_profile_and_passes_every_argument() -> None:
20
28
  assert "{profile_home}" in LAUNCHER_TEXT_TEMPLATE
21
29
  assert "claude %*" in LAUNCHER_TEXT_TEMPLATE
30
+
31
+
32
+ def test_picker_report_keys_never_collide() -> None:
33
+ all_report_keys = [
34
+ JSON_ACCOUNT_KEY,
35
+ JSON_CONFIG_DIRECTORY_KEY,
36
+ JSON_REASON_KEY,
37
+ JSON_METERS_KEY,
38
+ ]
39
+ all_meter_keys = [
40
+ JSON_SESSION_USED_PERCENT_KEY,
41
+ JSON_SESSION_RESETS_AT_KEY,
42
+ JSON_WEEKLY_USED_PERCENT_KEY,
43
+ JSON_WEEKLY_RESETS_AT_KEY,
44
+ ]
45
+ assert len(set(all_report_keys)) == len(all_report_keys)
46
+ assert len(set(all_meter_keys)) == len(all_meter_keys)
@@ -0,0 +1,29 @@
1
+ """Tests for the private-term digest table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from dev_env_scripts_constants.private_term_constants import (
8
+ ALL_EVENT_TEXT_FIELDS,
9
+ ALL_PRIVATE_TERM_DIGESTS,
10
+ )
11
+
12
+ _SHA256_HEX_PATTERN = re.compile(r"[0-9a-f]{64}")
13
+
14
+
15
+ def test_should_ship_only_digests_of_named_lengths() -> None:
16
+ assert ALL_PRIVATE_TERM_DIGESTS
17
+ for each_digest in ALL_PRIVATE_TERM_DIGESTS:
18
+ assert _SHA256_HEX_PATTERN.fullmatch(each_digest.sha256)
19
+ assert each_digest.length > 0
20
+
21
+
22
+ def test_should_read_every_text_a_pull_request_issue_comment_review_or_release_carries() -> None:
23
+ assert {each_object for each_object, _field, _label in ALL_EVENT_TEXT_FIELDS} == {
24
+ "pull_request",
25
+ "issue",
26
+ "comment",
27
+ "review",
28
+ "release",
29
+ }
@@ -12,6 +12,11 @@ from contrast_framing import describe_contrast_framing, find_contrast_framing
12
12
  from dev_env_scripts_constants.contrast_framing_constants import (
13
13
  CONTRAST_FRAMING_FINDING_CODE,
14
14
  )
15
+ from dev_env_scripts_constants.private_term_constants import (
16
+ ALL_PRIVATE_TERM_DIGESTS,
17
+ PRIVATE_TERM_FINDING_CODE,
18
+ PRIVATE_TERM_MESSAGE_TEMPLATE,
19
+ )
15
20
  from durable_post_lint_config.config.constants import (
16
21
  ACTION_PR_CREATE,
17
22
  ACTION_PR_EDIT,
@@ -41,6 +46,7 @@ from durable_post_lint_config.config.constants import (
41
46
  PATH_ANCHOR_CHARACTER,
42
47
  PATH_SEGMENT_START_CHARACTERS,
43
48
  RELEASE_BRANCH_PREFIX,
49
+ REPOSITORY_OWNER_SEPARATOR,
44
50
  REWRITTEN_RELEASE_BODY_CODE,
45
51
  REWRITTEN_RELEASE_BODY_MESSAGE,
46
52
  TITLE_NOT_ALLOWED_MESSAGE,
@@ -48,6 +54,7 @@ from durable_post_lint_config.config.constants import (
48
54
  VOLATILE_PATH_CODE,
49
55
  VOLATILE_PATH_MESSAGE,
50
56
  )
57
+ from private_terms import private_term_line_numbers
51
58
 
52
59
 
53
60
  class DurablePostUsageError(ValueError):
@@ -153,6 +160,26 @@ def _contrast_framing_findings(text: str) -> list[DurablePostFinding]:
153
160
  ]
154
161
 
155
162
 
163
+ def _private_term_findings(
164
+ text: str | None, repository: str | None
165
+ ) -> list[DurablePostFinding]:
166
+ if text is None:
167
+ return []
168
+ if repository is not None and private_term_line_numbers(
169
+ repository.split(REPOSITORY_OWNER_SEPARATOR)[0], ALL_PRIVATE_TERM_DIGESTS
170
+ ):
171
+ return []
172
+ return [
173
+ DurablePostFinding(
174
+ code=PRIVATE_TERM_FINDING_CODE,
175
+ message=PRIVATE_TERM_MESSAGE_TEMPLATE.format(line_number=each_line_number),
176
+ )
177
+ for each_line_number in private_term_line_numbers(
178
+ text, ALL_PRIVATE_TERM_DIGESTS
179
+ )
180
+ ]
181
+
182
+
156
183
  def _title_findings(title: str | None) -> list[DurablePostFinding]:
157
184
  if title is None:
158
185
  return []
@@ -215,12 +242,29 @@ def lint_durable_post(
215
242
  title: str | None,
216
243
  body_text: str | None,
217
244
  head_branch: str | None = None,
245
+ repository: str | None = None,
218
246
  ) -> tuple[DurablePostFinding, ...]:
219
- """Return content findings for one locally valid GitHub post request."""
247
+ """Return content findings for one locally valid GitHub post request.
248
+
249
+ A post to a repository whose owner is a private organization may name that
250
+ organization. Every other post, and a post with no repository given, may not.
251
+
252
+ Args:
253
+ action: The post action, such as ``pr-create`` or ``issue-comment``.
254
+ title: The post title, when the action carries one.
255
+ body_text: The post body, when the action carries one.
256
+ head_branch: The pull request head branch, when known.
257
+ repository: The ``owner/name`` the post targets, when known.
258
+
259
+ Returns:
260
+ Every finding, title findings first.
261
+ """
220
262
  _validate_request_shape(action, title, body_text)
221
263
  return (
222
264
  *_title_findings(title),
265
+ *_private_term_findings(title, repository),
223
266
  *_body_findings(action, body_text, head_branch),
267
+ *_private_term_findings(body_text, repository),
224
268
  )
225
269
 
226
270
 
@@ -230,6 +274,7 @@ def _parse_arguments(all_arguments: Sequence[str]) -> argparse.Namespace:
230
274
  parser.add_argument("--title")
231
275
  parser.add_argument("--body-file", type=Path)
232
276
  parser.add_argument("--head-branch")
277
+ parser.add_argument("--repository")
233
278
  return parser.parse_args(list(all_arguments))
234
279
 
235
280
 
@@ -247,6 +292,7 @@ def main(all_arguments: Sequence[str]) -> int:
247
292
  title=arguments.title,
248
293
  body_text=body_text,
249
294
  head_branch=arguments.head_branch,
295
+ repository=arguments.repository,
250
296
  )
251
297
  except (DurablePostInputError, DurablePostUsageError) as error:
252
298
  sys.stderr.write(f"{error}\n")
@@ -101,3 +101,4 @@ REWRITTEN_RELEASE_BODY_MESSAGE = (
101
101
  "release automation reads this body back to recognize its own merge; "
102
102
  "keep the generated text whole"
103
103
  )
104
+ REPOSITORY_OWNER_SEPARATOR = "/"
@@ -5,8 +5,8 @@ A durable GitHub post (issue, PR, comment, review) must not link a file under a
5
5
  job scratch directory or worktree, because that scratch is cleaned soon after
6
6
  the run while the post lives forever. Binary artifacts belong in a permanent
7
7
  place instead. This tool ensures the repo has a prerelease tagged ``artifacts``,
8
- uploads the given file under a timestamped asset name, and prints the permanent
9
- download URL a post can safely link. The timestamped name keeps each upload a
8
+ shrinks a PNG losslessly with oxipng, uploads the file under a timestamped asset
9
+ name, and prints the permanent download URL a post can safely link. The timestamped name keeps each upload a
10
10
  distinct asset. An upload never overwrites an earlier one. A same-name collision
11
11
  fails loudly instead of replacing the bytes an existing URL already serves.
12
12
 
@@ -28,6 +28,7 @@ import tempfile
28
28
  from pathlib import Path
29
29
 
30
30
  from dev_env_scripts_constants.gh_artifact_upload_constants import (
31
+ ALL_OXIPNG_LOSSLESS_ARGUMENTS,
31
32
  ARTIFACTS_RELEASE_NOTES,
32
33
  ARTIFACTS_RELEASE_TAG,
33
34
  ARTIFACTS_RELEASE_TITLE,
@@ -37,7 +38,11 @@ from dev_env_scripts_constants.gh_artifact_upload_constants import (
37
38
  ASSET_URL_JSON_KEY,
38
39
  GH_BINARY_NAME,
39
40
  NOTES_FILE_SUFFIX,
41
+ OXIPNG_BINARY_NAME,
42
+ OXIPNG_MISSING_MESSAGE,
43
+ PNG_SUFFIX,
40
44
  RELEASE_ASSETS_JSON_KEY,
45
+ SIZE_REPORT_TEMPLATE,
41
46
  UTF8_ENCODING,
42
47
  )
43
48
 
@@ -132,6 +137,49 @@ def timestamped_asset_name(file_path: str) -> str:
132
137
  )
133
138
 
134
139
 
140
+ def _run_oxipng(oxipng_path: str, staged_path: Path) -> None:
141
+ completion = subprocess.run(
142
+ [oxipng_path, *ALL_OXIPNG_LOSSLESS_ARGUMENTS, str(staged_path)],
143
+ capture_output=True,
144
+ text=True,
145
+ encoding=UTF8_ENCODING,
146
+ check=False,
147
+ creationflags=hidden_window_creation_flags(),
148
+ )
149
+ if completion.returncode != 0:
150
+ raise ArtifactUploadError(completion.stderr.strip())
151
+
152
+
153
+ def optimize_png_losslessly(staged_path: Path) -> None:
154
+ """Shrink a staged PNG in place with oxipng and report both sizes.
155
+
156
+ ::
157
+
158
+ contact_sheet.png: 361105 -> 308782 bytes
159
+
160
+ Oxipng keeps every pixel and every chunk. Other suffixes stay as they are.
161
+
162
+ Args:
163
+ staged_path: The staged copy that the upload sends.
164
+
165
+ Raises:
166
+ ArtifactUploadError: When oxipng is missing or fails.
167
+ """
168
+ if staged_path.suffix.lower() != PNG_SUFFIX:
169
+ return
170
+ oxipng_path = shutil.which(OXIPNG_BINARY_NAME)
171
+ if oxipng_path is None:
172
+ raise ArtifactUploadError(OXIPNG_MISSING_MESSAGE)
173
+ original_size = staged_path.stat().st_size
174
+ _run_oxipng(oxipng_path, staged_path)
175
+ size_report = SIZE_REPORT_TEMPLATE.format(
176
+ name=staged_path.name,
177
+ original_size=original_size,
178
+ optimized_size=staged_path.stat().st_size,
179
+ )
180
+ print(size_report, file=sys.stderr)
181
+
182
+
135
183
  def _uploaded_asset_download_url(repository: str) -> str:
136
184
  """Read the just-uploaded asset's real download URL back from GitHub.
137
185
 
@@ -197,6 +245,9 @@ def _uploaded_asset_download_url(repository: str) -> str:
197
245
  def upload_artifact(file_path: str, repository: str) -> str:
198
246
  """Upload a file to the artifacts release and return its permanent URL.
199
247
 
248
+ A PNG passes through oxipng on a staged copy first, so the source file
249
+ stays unchanged.
250
+
200
251
  Args:
201
252
  file_path: The source file to upload.
202
253
  repository: The ``owner/repo`` slug.
@@ -205,7 +256,8 @@ def upload_artifact(file_path: str, repository: str) -> str:
205
256
  The permanent download URL for the uploaded asset.
206
257
 
207
258
  Raises:
208
- ArtifactUploadError: When the file is missing or the upload fails.
259
+ ArtifactUploadError: When the file is missing, oxipng cannot shrink
260
+ a PNG, or the upload fails.
209
261
  """
210
262
  source_path = Path(file_path)
211
263
  if not source_path.is_file():
@@ -215,6 +267,7 @@ def upload_artifact(file_path: str, repository: str) -> str:
215
267
  with tempfile.TemporaryDirectory() as staging_directory:
216
268
  staged_asset_path = Path(staging_directory) / asset_name
217
269
  shutil.copyfile(source_path, staged_asset_path)
270
+ optimize_png_losslessly(staged_asset_path)
218
271
  completion = _run_gh(
219
272
  [
220
273
  "release",