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.
- package/.agents/skills/privacy-hygiene/SKILL.md +1 -0
- package/.agents/skills/pull-request/scripts/pull_request.py +1 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +2 -0
- package/bin/install-constants.mjs +5 -0
- package/bin/install.cursor-rules.test.mjs +6 -0
- package/bin/install.mjs +2 -2
- package/docs/second-claude-account.md +8 -1
- package/hooks/hooks.json +37 -0
- package/hooks/hooks_constants/skill_loaded_reminder_constants.py +70 -0
- package/hooks/session/skill_loaded_reminder.py +214 -0
- package/hooks/session/test_skill_loaded_reminder.py +253 -0
- package/package.json +1 -1
- package/rules/durable-post-artifacts.md +26 -1
- package/rules/prefer-existing-tools.md +35 -0
- package/rules/review-closure-is-a-check.md +1 -1
- package/scripts/claude_account_choice.py +51 -6
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +15 -0
- package/scripts/dev_env_scripts_constants/gh_artifact_upload_constants.py +27 -0
- package/scripts/dev_env_scripts_constants/private_term_constants.py +60 -0
- package/scripts/dev_env_scripts_constants/test_claude_account_constants.py +25 -0
- package/scripts/dev_env_scripts_constants/test_private_term_constants.py +29 -0
- package/scripts/durable_post_lint.py +47 -1
- package/scripts/durable_post_lint_config/config/constants.py +1 -0
- package/scripts/gh_artifact_upload.py +56 -3
- package/scripts/private_term_scan.py +135 -0
- package/scripts/private_terms.py +87 -0
- package/scripts/repository_checks/config/constants.py +3 -0
- package/scripts/repository_checks/runner.py +7 -0
- package/scripts/repository_checks/tracked_private_terms.py +57 -0
- package/scripts/test_claude_account_choice.py +28 -0
- package/scripts/test_durable_post_lint.py +90 -0
- package/scripts/tests/test_gh_artifact_upload.py +75 -0
- package/scripts/tests/test_match_exemptions.py +2 -2
- package/scripts/tests/test_private_term_scan.py +176 -0
- package/scripts/tests/test_private_terms.py +59 -0
- package/scripts/tests/test_repository_policy.py +2 -0
- package/scripts/tests/test_runner.py +20 -0
- package/scripts/tests/test_tracked_private_terms.py +71 -0
- package/scripts/tests/test_tracked_secrets.py +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
235
|
-
second_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
|
-
|
|
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")
|
|
@@ -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
|
|
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
|
|
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",
|