@runeditrun/rer 0.1.1 → 0.2.1
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/README.md +113 -2
- package/cli/README.md +32 -5
- package/cli/contract.mjs +5 -5
- package/cli/deploy.mjs +17 -3
- package/cli/detached-server.mjs +51 -0
- package/cli/dev-identity.mjs +2 -1
- package/cli/door-mode.mjs +134 -0
- package/cli/door.mjs +263 -0
- package/cli/hosted-output.mjs +17 -7
- package/cli/hydration/browser.mjs +91 -0
- package/cli/hydration/cases.mjs +106 -0
- package/cli/hydration/check.mjs +105 -0
- package/cli/hydration/probe.mjs +164 -0
- package/cli/hydration/scripts.mjs +39 -0
- package/cli/hydration/verdict.mjs +85 -0
- package/cli/inspect.mjs +57 -10
- package/cli/lifecycle.mjs +36 -12
- package/cli/local.mjs +9 -3
- package/cli/main.mjs +3 -0
- package/cli/managed-server.mjs +62 -21
- package/cli/package-calls.mjs +81 -0
- package/cli/package-mode.mjs +46 -0
- package/cli/parse.mjs +15 -4
- package/cli/pnpm-runtime.mjs +96 -0
- package/cli/portability-worker.mjs +2 -2
- package/cli/release.mjs +1 -0
- package/cli/runtime-front-door.mjs +4 -2
- package/cli/server-proxy.mjs +69 -5
- package/cli/server-registry.mjs +10 -7
- package/cli/settings.mjs +14 -0
- package/cli/site-bindings.mjs +291 -0
- package/cli/site-browser-objects.mjs +36 -0
- package/cli/site-check.mjs +111 -0
- package/cli/site-client.mjs +50 -0
- package/cli/site-data.mjs +54 -0
- package/cli/site-entities.json +1 -0
- package/cli/site-input-assets.mjs +58 -0
- package/cli/site-inputs.mjs +79 -0
- package/cli/site-markup.mjs +102 -0
- package/cli/site-preview.mjs +53 -0
- package/cli/site-private.mjs +36 -0
- package/cli/site-references.mjs +93 -0
- package/cli/site-render.mjs +79 -0
- package/cli/site-syntax.mjs +21 -0
- package/cli/site.mjs +130 -0
- package/global/CHANGELOG.md +88 -0
- package/global/README.md +113 -2
- package/global/checkout.mjs +24 -10
- package/global/cli/install.mjs +6 -2
- package/global/delegate.mjs +9 -7
- package/global/door/command.mjs +254 -0
- package/global/door/control.mjs +65 -0
- package/global/door/cookies.mjs +41 -0
- package/global/door/pages.mjs +49 -0
- package/global/door/policy.mjs +49 -0
- package/global/door/routes.mjs +45 -0
- package/global/door/server.mjs +255 -0
- package/global/door/sites.mjs +192 -0
- package/global/launch/dev.mjs +21 -7
- package/global/launch/door.mjs +36 -0
- package/global/launch/install-pinned.mjs +22 -0
- package/global/launch/prepare.mjs +38 -5
- package/global/launch/render.mjs +6 -1
- package/global/launch/steps.mjs +12 -7
- package/global/launch/worker.mjs +3 -3
- package/global/list.mjs +6 -3
- package/global/main.mjs +39 -3
- package/global/output.mjs +12 -0
- package/global/rer.mjs +3 -0
- package/global/version.mjs +8 -8
- package/modules/dependencies/protocol.mjs +2 -1
- package/modules/deploy-kit/README.md +34 -1
- package/modules/deploy-kit/artifact.mjs +10 -0
- package/modules/deploy-kit/deploy.mjs +5 -2
- package/modules/deploy-kit/diagnostics.mjs +55 -0
- package/modules/deploy-kit/owner-cli.mjs +10 -4
- package/modules/deploy-kit/owner.mjs +8 -4
- package/modules/deploy-kit/prepare.mjs +3 -2
- package/modules/deploy-kit/provider.mjs +51 -9
- package/modules/deploy-kit/site.mjs +135 -0
- package/modules/erasure/CONTRACT.md +6 -3
- package/modules/erasure/README.md +8 -1
- package/modules/erasure/audit.mjs +2 -2
- package/modules/erasure/cascade.mjs +52 -11
- package/modules/erasure/order.mjs +29 -22
- package/modules/erasure/plan.mjs +4 -2
- package/modules/erasure/queue.mjs +41 -0
- package/modules/erasure/scope.mjs +3 -3
- package/modules/erasure/verification.mjs +4 -2
- package/modules/mail/README.md +1 -1
- package/modules/mail/index.mjs +1 -1
- package/modules/mail/src/core/errors.mjs +9 -0
- package/modules/mail/src/development/index.d.mts +6 -0
- package/modules/mail/src/development/index.mjs +6 -3
- package/modules/mail/src/development/viewer-html.mjs +3 -3
- package/modules/mail/src/development/viewer.mjs +8 -1
- package/modules/operator-auth/accounts/native.mjs +1 -1
- package/modules/operator-auth/dev/badge.mjs +4 -1
- package/modules/operator-auth/dev/index.mjs +15 -5
- package/modules/operator-auth/http.mjs +2 -3
- package/modules/operator-auth/index.d.mts +4 -9
- package/modules/operator-auth/loopback.mjs +17 -0
- package/modules/operator-auth/page.mjs +1 -1
- package/modules/operator-auth/service.mjs +1 -0
- package/modules/operator-auth/signin/index.d.mts +11 -0
- package/modules/operator-auth/signin/index.mjs +10 -7
- package/modules/owner-claim/verify.mjs +2 -1
- package/modules/portability/README.md +8 -0
- package/modules/portability/hash.mjs +6 -2
- package/modules/portability/import.mjs +1 -1
- package/modules/portability/legacy-files.mjs +4 -4
- package/modules/portability/legacy-safety.mjs +51 -10
- package/modules/portability/migration-history.mjs +15 -0
- package/modules/release/README.md +68 -3
- package/modules/release/keygen.mjs +18 -0
- package/modules/release/produce.mjs +14 -2
- package/modules/release/sign.mjs +44 -0
- package/modules/release/signature.mjs +336 -0
- package/modules/release/signing-cli.mjs +56 -0
- package/modules/release/signing-io.mjs +61 -0
- package/modules/release/signing-key.mjs +22 -0
- package/modules/release/source.mjs +15 -4
- package/modules/release/statement.mjs +80 -0
- package/modules/release/vectors/generate.mjs +381 -0
- package/modules/release/vectors/release-signature-vectors.json +2305 -0
- package/modules/release/verify.mjs +27 -0
- package/onboarding/adapter-auth.mjs +4 -3
- package/onboarding/adapter-settings.mjs +21 -5
- package/onboarding/adapter.mjs +2 -1
- package/onboarding/app-fetch.mjs +79 -0
- package/onboarding/archive-output.d.mts +4 -2
- package/onboarding/archive-output.mjs +22 -10
- package/onboarding/dependency-approval.mjs +4 -3
- package/onboarding/local/environment.mjs +7 -2
- package/onboarding/operator-install.mjs +8 -2
- package/onboarding/operator-prerequisites.mjs +5 -2
- package/onboarding/production-assets.mjs +23 -0
- package/onboarding/production-runtime.mjs +8 -4
- package/onboarding/queued-entry.mjs +9 -5
- package/operations/operations.mjs +11 -2
- package/package.json +76 -1
- package/scripts/publish-check/README.md +27 -17
- package/scripts/publish-check/content_identities.py +14 -233
- package/scripts/publish-check/git_history.py +2 -2
- package/scripts/publish-check/github_refs.py +28 -2
- package/scripts/publish-check/gitleaks.toml +1 -1
- package/scripts/publish-check/history_scan.py +6 -4
- package/scripts/publish-check/hygiene_config.py +3 -1
- package/scripts/publish-check/public_hygiene.py +63 -8
- package/scripts/publish-check/publication-config.json +16 -1
- package/scripts/publish-check/source_metadata.py +62 -0
- package/scripts/publish-check/source_shapes.py +91 -0
- package/scripts/publish-check/tree_scan.py +2 -2
- package/scripts/publish-check/source_hashes.mjs +0 -6
|
@@ -8,32 +8,42 @@ blob reads to system Git (`rev-parse`, `rev-list`, `ls-tree`, `cat-file --batch`
|
|
|
8
8
|
its report private until translating it into the value-free v3 envelope.
|
|
9
9
|
Hexadecimal credential assignments also use a Gitleaks rule without generic
|
|
10
10
|
entropy or English stopword filters. Both assignment rules go through the same
|
|
11
|
-
location-bound Source/module identity
|
|
11
|
+
location-bound Source/module identity classification; provider and environment findings
|
|
12
12
|
remain independent.
|
|
13
13
|
`tree_scan.py` applies the kept text and licence policy. `history_scan.py`
|
|
14
14
|
assigns release, working-tree and historical scopes. The Node entry point bounds
|
|
15
15
|
the whole operation and stops its private process group on timeout/cancellation.
|
|
16
16
|
|
|
17
|
+
Git message records carry their kind explicitly. Only commit records enable the
|
|
18
|
+
bounded public coauthor address rule described in the command guide; a file named
|
|
19
|
+
`@commit-message` has no special status. `public_hygiene.py` matches complete raw
|
|
20
|
+
`Co-Authored-By` lines in the final trailer paragraph case-insensitively and
|
|
21
|
+
masks only the accepted address for email inspection, leaving names, other
|
|
22
|
+
addresses and every other rule intact.
|
|
23
|
+
|
|
17
24
|
Binary metadata decoding, Git protocol/object/ref modelling, frozen comparison,
|
|
18
25
|
resumable inspection and their tests have been removed. Tests live in `tests.py`
|
|
19
26
|
and `coverage_tests.py` and construct disposable repositories with actual Git.
|
|
20
27
|
|
|
21
|
-
`content_identities.py`
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
`content_identities.py` binds generic-api-key and hex-api-key occurrences to
|
|
29
|
+
exact JSON value locations. `source_metadata.py` enumerates digest fields in the
|
|
30
|
+
formats emitted by `edition-authoring/cli.mjs` and `sources.mjs`: lock manifest,
|
|
31
|
+
toolchain, parent/baseline pins and file SHA-256 values; retained archive keys,
|
|
32
|
+
content digests and dependency pins; and the source origins' revision or
|
|
33
|
+
projection identities. These are format classifications independent of selected
|
|
34
|
+
graphs, canonical serialization, archive preimages and authoring freshness.
|
|
35
|
+
`source_shapes.py` first checks the documented local record types, immutable
|
|
36
|
+
origin identity, source selections and file/pin records. A malformed archive or
|
|
37
|
+
lock grants no identity roles; unknown extra fields never gain a role.
|
|
38
|
+
`provenance_pins.py` keeps the existing module revision classification.
|
|
39
|
+
|
|
40
|
+
`json_identity.py` rejects duplicate members and non-JSON constants and binds
|
|
41
|
+
native findings to exact string byte spans. Only documented structural fields
|
|
42
|
+
with the declared digest length qualify. An extra credential field, arbitrary
|
|
43
|
+
nested field or copy of the digest on the same minified line remains a finding.
|
|
44
|
+
Provider and known environment findings are independent. Native git/dir matches
|
|
45
|
+
stay in the private temporary report and memory; values are never added to the
|
|
46
|
+
public report. Authoring's integrity checks remain unchanged.
|
|
37
47
|
|
|
38
48
|
`copy_identities.py` proves the maintained `base-copies.json` and portable
|
|
39
49
|
`base/PROVENANCE.json` copy-inventory SHA-256 values
|
|
@@ -1,249 +1,30 @@
|
|
|
1
|
-
"""Classify
|
|
2
|
-
|
|
3
|
-
This is classification, not Source authoring validation. A stale lock still
|
|
4
|
-
fails the authoring check. Missing original preimages remain ordinary findings.
|
|
5
|
-
"""
|
|
6
|
-
import base64
|
|
7
|
-
import hashlib
|
|
8
|
-
import re
|
|
9
|
-
import json
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
import subprocess
|
|
12
|
-
from json_identity import Document, HEX
|
|
1
|
+
"""Classify documented JSON identities at exact native-match locations."""
|
|
2
|
+
from json_identity import Document
|
|
13
3
|
from provenance_pins import module_pins
|
|
14
4
|
from copy_identities import copy_pointers
|
|
15
|
-
|
|
16
|
-
ARCHIVE = re.compile(r'\.edition/sources/([a-f0-9]{64})\.json\Z')
|
|
5
|
+
from source_metadata import ARCHIVE, source_pointers
|
|
17
6
|
|
|
18
7
|
|
|
19
8
|
def candidate(path):
|
|
20
9
|
return path in ('edition.json', 'sources.lock.json', 'base-copies.json', 'base/PROVENANCE.json') or bool(ARCHIVE.fullmatch(path))
|
|
21
10
|
|
|
22
11
|
|
|
23
|
-
def safe_path(value):
|
|
24
|
-
return (isinstance(value, str) and value and not re.search(r'[\\\x00-\x1f\x7f]', value)
|
|
25
|
-
and all(part not in ('', '.', '..', '.git') for part in value.split('/')))
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def valid_files(files):
|
|
29
|
-
if not isinstance(files, dict) or not files:
|
|
30
|
-
return False
|
|
31
|
-
for name, file in files.items():
|
|
32
|
-
if (not safe_path(name) or not isinstance(file, dict) or not isinstance(file.get('data'), str)
|
|
33
|
-
or type(file.get('executable')) is not bool):
|
|
34
|
-
return False
|
|
35
|
-
try:
|
|
36
|
-
if base64.b64encode(base64.b64decode(file['data'], validate=True)).decode() != file['data']:
|
|
37
|
-
return False
|
|
38
|
-
except ValueError:
|
|
39
|
-
return False
|
|
40
|
-
return True
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def same_json(left, right):
|
|
44
|
-
# Python equates booleans and numbers; Source JSON identities do not.
|
|
45
|
-
return json.dumps(left, sort_keys=True) == json.dumps(right, sort_keys=True)
|
|
46
|
-
|
|
47
|
-
|
|
48
12
|
class ContentIdentities:
|
|
49
13
|
def __init__(self, files, read_regular=None):
|
|
50
|
-
self.documents, self.allowed
|
|
14
|
+
self.documents, self.allowed = {}, {}
|
|
51
15
|
for path, data in files.items():
|
|
52
|
-
if candidate(path):
|
|
53
|
-
try:
|
|
54
|
-
self.documents[path] = Document(data)
|
|
55
|
-
except (ValueError, TypeError, IndexError, RecursionError, UnicodeError):
|
|
56
|
-
pass
|
|
57
|
-
if not self.documents:
|
|
58
|
-
return
|
|
59
|
-
for path, document in self.documents.items():
|
|
60
|
-
for pointer in module_pins(path, document.value):
|
|
61
|
-
self.allow(path, pointer)
|
|
62
|
-
if path in ('base-copies.json', 'base/PROVENANCE.json') and read_regular is not None:
|
|
63
|
-
for pointer in copy_pointers(document.value, read_regular):
|
|
64
|
-
self.allow(path, pointer)
|
|
65
|
-
recipe = self.document('edition.json')
|
|
66
|
-
origins = [self.origin(recipe.get('parent'), True)]
|
|
67
|
-
components = recipe.get('components', [])
|
|
68
|
-
if isinstance(components, list):
|
|
69
|
-
for component in components:
|
|
70
|
-
source = component.get('source') if isinstance(component, dict) else None
|
|
71
|
-
if isinstance(source, dict):
|
|
72
|
-
origins.append(self.origin(source if source.get('kind') == 'git' else source.get('origin')))
|
|
73
|
-
values = [recipe, *(v for v in origins if v is not None)]
|
|
74
|
-
for path, document in self.documents.items():
|
|
75
|
-
saved = document.value
|
|
76
|
-
if ARCHIVE.fullmatch(path) and isinstance(saved, dict):
|
|
77
|
-
values.extend([saved, saved.get('origin'), saved.get('files')])
|
|
78
|
-
self.hashes = {}
|
|
79
|
-
if values:
|
|
80
|
-
try:
|
|
81
|
-
result = subprocess.run(['node', str(Path(__file__).with_name('source_hashes.mjs'))],
|
|
82
|
-
input=json.dumps(values).encode(), capture_output=True, timeout=60, check=True)
|
|
83
|
-
hashes = json.loads(result.stdout)
|
|
84
|
-
if len(hashes) != len(values) or not all(isinstance(h, str) and HEX.fullmatch(h) for h in hashes):
|
|
85
|
-
return
|
|
86
|
-
self.hashes = {json.dumps(v): h for v, h in zip(values, hashes)}
|
|
87
|
-
except (OSError, ValueError, subprocess.SubprocessError):
|
|
88
|
-
return
|
|
89
|
-
# Authoring pins hash(json(saved)); a raw-byte pin alone cannot prove a
|
|
90
|
-
# differently encoded retained archive, even when its fields agree.
|
|
91
|
-
for path, document in self.documents.items():
|
|
92
|
-
saved, match = document.value, ARCHIVE.fullmatch(path)
|
|
93
|
-
if (match and isinstance(saved, dict) and saved.get('key') == match[1]
|
|
94
|
-
and isinstance(saved.get('origin'), dict) and saved['origin']
|
|
95
|
-
and valid_files(saved.get('files'))
|
|
96
|
-
and isinstance(saved.get('dependencies', []), list)
|
|
97
|
-
and saved['key'] == self.digest(saved['origin'])
|
|
98
|
-
and saved.get('digest') == self.digest(saved['files'])
|
|
99
|
-
and hashlib.sha256(document.data).hexdigest() == self.digest(saved)):
|
|
100
|
-
self.archives[saved['key']] = (saved, hashlib.sha256(document.data).hexdigest())
|
|
101
|
-
self.references()
|
|
102
|
-
|
|
103
|
-
def digest(self, value):
|
|
104
|
-
return self.hashes.get(json.dumps(value))
|
|
105
|
-
|
|
106
|
-
def allow(self, path, pointer):
|
|
107
|
-
self.allowed.setdefault(path, set()).add(pointer)
|
|
108
|
-
|
|
109
|
-
def document(self, path):
|
|
110
|
-
doc = self.documents.get(path)
|
|
111
|
-
return doc.value if doc and isinstance(doc.value, dict) else {}
|
|
112
|
-
|
|
113
|
-
def pin(self, pin, key):
|
|
114
|
-
if not isinstance(pin, dict) or not isinstance(key, str) or key not in self.archives:
|
|
115
|
-
return False
|
|
116
|
-
archive, raw_sha = self.archives[key]
|
|
117
|
-
return (pin.get('key') == key and pin.get('digest') == archive['digest']
|
|
118
|
-
and pin.get('sha256') == raw_sha)
|
|
119
|
-
|
|
120
|
-
def reference(self, ref, parent=False):
|
|
121
|
-
if not isinstance(ref, dict):
|
|
122
|
-
return None
|
|
123
|
-
if not self.valid_selection(ref):
|
|
124
|
-
return None
|
|
125
|
-
selection = dict(path=ref.get('path') or '.', paths=ref.get('paths') or None,
|
|
126
|
-
excludeSnapshots=True if parent else bool(ref.get('excludeSnapshots')))
|
|
127
|
-
projection = ref.get('projection')
|
|
128
|
-
if isinstance(projection, dict) and set(projection) == {'key', 'sha256'}:
|
|
129
|
-
key = projection['key']
|
|
130
|
-
if not isinstance(key, str) or key not in self.archives:
|
|
131
|
-
return None
|
|
132
|
-
saved, raw_sha = self.archives[key]
|
|
133
|
-
if (projection['sha256'] == raw_sha and same_json(saved['origin'].get('selection'), selection)):
|
|
134
|
-
return key
|
|
135
|
-
return None
|
|
136
|
-
origin = self.origin(ref, parent)
|
|
137
|
-
key = self.digest(origin)
|
|
138
|
-
if key in self.archives and same_json(self.archives[key][0]['origin'], origin):
|
|
139
|
-
return key
|
|
140
|
-
return None
|
|
141
|
-
|
|
142
|
-
@staticmethod
|
|
143
|
-
def valid_selection(ref):
|
|
144
|
-
return (ref.get('path', '.') == '.' or safe_path(ref.get('path'))) and (
|
|
145
|
-
ref.get('paths') is None or isinstance(ref['paths'], list) and bool(ref['paths'])
|
|
146
|
-
and all(safe_path(p) for p in ref['paths'])) and (
|
|
147
|
-
'excludeSnapshots' not in ref or type(ref['excludeSnapshots']) is bool)
|
|
148
|
-
|
|
149
|
-
@staticmethod
|
|
150
|
-
def origin(ref, parent=False):
|
|
151
|
-
if not isinstance(ref, dict) or not ContentIdentities.valid_selection(ref):
|
|
152
|
-
return None
|
|
153
|
-
projection = ref.get('projection')
|
|
154
|
-
if (not isinstance(ref.get('repository'), str) or not ref['repository']
|
|
155
|
-
or ref['repository'].startswith('-')):
|
|
156
|
-
return None
|
|
157
|
-
if 'projection' in ref:
|
|
158
|
-
if ('revision' in ref or not isinstance(projection, dict) or set(projection) != {'schemaVersion', 'originalSha256', 'recipeSha256'}
|
|
159
|
-
or type(projection['schemaVersion']) is not int or projection['schemaVersion'] != 1
|
|
160
|
-
or not all(isinstance(projection[k], str) and HEX.fullmatch(projection[k])
|
|
161
|
-
for k in ('originalSha256', 'recipeSha256'))):
|
|
162
|
-
return None
|
|
163
|
-
identity = dict(projection=projection)
|
|
164
|
-
elif isinstance(ref.get('revision'), str) and re.fullmatch(r'[a-f0-9]{40}', ref['revision']):
|
|
165
|
-
identity = dict(revision=ref['revision'])
|
|
166
|
-
else:
|
|
167
|
-
return None
|
|
168
|
-
return dict(repository=ref['repository'], **identity, path=ref.get('path') or '.',
|
|
169
|
-
paths=ref.get('paths') or None,
|
|
170
|
-
excludeSnapshots=True if parent else bool(ref.get('excludeSnapshots')))
|
|
171
|
-
|
|
172
|
-
def dependency_locations(self, key):
|
|
173
|
-
# Stage the entire selected closure before granting any of its locations.
|
|
174
|
-
# An unresolved later edge must not leave the root or earlier edges allowed.
|
|
175
|
-
pending, visited, locations = [key], set(), []
|
|
176
|
-
while pending:
|
|
177
|
-
key = pending.pop()
|
|
178
|
-
if key in visited:
|
|
16
|
+
if not candidate(path):
|
|
179
17
|
continue
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
locations.append((path, ('key',)))
|
|
184
|
-
for i, pin in enumerate(saved.get('dependencies', [])):
|
|
185
|
-
if not isinstance(pin, dict) or not self.pin(pin, pin.get('key')):
|
|
186
|
-
return None
|
|
187
|
-
locations.append((path, ('dependencies', i, 'key')))
|
|
188
|
-
pending.append(pin['key'])
|
|
189
|
-
return locations
|
|
190
|
-
|
|
191
|
-
def references(self):
|
|
192
|
-
recipe = self.document('edition.json')
|
|
193
|
-
lock = self.document('sources.lock.json')
|
|
194
|
-
if type(recipe.get('schemaVersion')) is not int or recipe['schemaVersion'] != 1 or not isinstance(recipe.get('components'), list):
|
|
195
|
-
return
|
|
196
|
-
components = lock.get('components')
|
|
197
|
-
locked = (type(lock.get('schemaVersion')) is int and lock['schemaVersion'] == 1
|
|
198
|
-
and isinstance(recipe.get('id'), str) and lock.get('edition') == recipe['id']
|
|
199
|
-
and lock.get('manifest') == self.digest(recipe) and isinstance(components, list))
|
|
200
|
-
if not locked:
|
|
201
|
-
return
|
|
202
|
-
def identifiers(entries):
|
|
203
|
-
ids = [entry.get('id') for entry in entries if isinstance(entry, dict)]
|
|
204
|
-
return (len(ids) == len(entries) and all(isinstance(i, str) and i for i in ids)
|
|
205
|
-
and len(set(ids)) == len(ids))
|
|
206
|
-
if not identifiers(recipe['components']) or not identifiers(components):
|
|
207
|
-
return
|
|
208
|
-
by_id = {c['id']: c for c in components}
|
|
209
|
-
if (len(components) != len(recipe['components']) or any(
|
|
210
|
-
c['id'] not in by_id or not same_json(c.get('source'), by_id[c['id']].get('source'))
|
|
211
|
-
for c in recipe['components'])):
|
|
212
|
-
return
|
|
213
|
-
selected = []
|
|
214
|
-
key = self.reference(recipe.get('parent'), parent=True)
|
|
215
|
-
if key and locked and self.pin(lock.get('parent'), key):
|
|
216
|
-
selected.append((key, [
|
|
217
|
-
('edition.json', ('parent', 'projection', 'key')),
|
|
218
|
-
('sources.lock.json', ('parent', 'key')),
|
|
219
|
-
]))
|
|
220
|
-
for index, component in enumerate(recipe['components']):
|
|
221
|
-
if not isinstance(component, dict):
|
|
222
|
-
continue
|
|
223
|
-
source = component.get('source')
|
|
224
|
-
if not isinstance(source, dict):
|
|
225
|
-
continue
|
|
226
|
-
suffix = () if source.get('kind') == 'git' else ('origin',) if source.get('kind') == 'local' else None
|
|
227
|
-
if suffix is None:
|
|
228
|
-
continue
|
|
229
|
-
key = self.reference(source if not suffix else source.get('origin'))
|
|
230
|
-
if not key:
|
|
18
|
+
try:
|
|
19
|
+
document = Document(data)
|
|
20
|
+
except (ValueError, TypeError, IndexError, RecursionError, UnicodeError):
|
|
231
21
|
continue
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
('sources.lock.json', ('components', i, 'source', *suffix, 'projection', 'key')),
|
|
239
|
-
]))
|
|
240
|
-
# Validate each selection independently: unselected archives and unrelated
|
|
241
|
-
# coherent selections cannot invalidate this graph's proof.
|
|
242
|
-
for key, locations in selected:
|
|
243
|
-
dependencies = self.dependency_locations(key)
|
|
244
|
-
if dependencies is not None:
|
|
245
|
-
for path, pointer in locations + dependencies:
|
|
246
|
-
self.allow(path, pointer)
|
|
22
|
+
self.documents[path] = document
|
|
23
|
+
pointers = set(module_pins(path, document.value))
|
|
24
|
+
pointers.update(source_pointers(path, document.value))
|
|
25
|
+
if path in ('base-copies.json', 'base/PROVENANCE.json') and read_regular is not None:
|
|
26
|
+
pointers.update(copy_pointers(document.value, read_regular))
|
|
27
|
+
self.allowed[path] = pointers
|
|
247
28
|
|
|
248
29
|
def matches(self, path, hit):
|
|
249
30
|
document = self.documents.get(path)
|
|
@@ -96,12 +96,12 @@ class History:
|
|
|
96
96
|
records = self.run('log', '-z', '--format=%H%x00%B', *self.revisions).split(b'\0')
|
|
97
97
|
for index in range(0, len(records) - 1, 2):
|
|
98
98
|
if records[index]:
|
|
99
|
-
yield '
|
|
99
|
+
yield 'commit', records[index].decode().strip(), records[index + 1]
|
|
100
100
|
for record in self.run('for-each-ref', '--format=%(objecttype) %(objectname)', 'refs/tags').splitlines():
|
|
101
101
|
kind, oid = record.decode().split()
|
|
102
102
|
if kind == 'tag':
|
|
103
103
|
raw = self.run('cat-file', 'tag', oid)
|
|
104
|
-
yield '
|
|
104
|
+
yield 'tag', oid, raw.partition(b'\n\n')[2]
|
|
105
105
|
|
|
106
106
|
def close(self):
|
|
107
107
|
if self.reader:
|
|
@@ -51,6 +51,10 @@ CONTEXT_URL = re.compile(r'(?i)(?<![A-Za-z])(?:' + REPOSITORY_CONTEXT + r')'
|
|
|
51
51
|
+ r'''[`*_~\[\]\s"']*(?:''' + URL.pattern.replace('(?i)', '') + ')')
|
|
52
52
|
LINK_URL = re.compile(r'(?i)\[(?P<label>[^]\n]+)\]\(\s*(?P<url>' + URL.pattern.replace('(?i)', '') + ')')
|
|
53
53
|
REPOSITORY_LABEL = re.compile(r'(?i)\b(?:' + REPOSITORY_CONTEXT + r')\b')
|
|
54
|
+
# Words that make a nearby `owner/name` a repository rather than an identifier.
|
|
55
|
+
# A bare "source" is not one: "source commit/tree" is ordinary prose.
|
|
56
|
+
NEARBY_CONTEXT = re.compile(r'(?i)\b(?:github|repositor(?:y|ies)|repos?|forks?|forked|clones?|cloned|upstream|downstream|origin|submodules?)\b')
|
|
57
|
+
CONTEXT_WINDOW = 60
|
|
54
58
|
|
|
55
59
|
|
|
56
60
|
def explicit_references(text, path):
|
|
@@ -76,6 +80,20 @@ class RepositoryObject(dict):
|
|
|
76
80
|
|
|
77
81
|
|
|
78
82
|
def references(text, path, local_paths=frozenset(), project_identity=None):
|
|
83
|
+
stated, ambiguous = reference_sets(text, path, local_paths, project_identity)
|
|
84
|
+
return stated | ambiguous
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def reference_sets(text, path, local_paths=frozenset(), project_identity=None):
|
|
88
|
+
"""Split `owner/name` tokens into stated repositories and ambiguous ones.
|
|
89
|
+
|
|
90
|
+
A stated reference has GitHub context: a URL, a repository field, a labelled
|
|
91
|
+
or commanded mention, a repository route. An ambiguous token is a bare
|
|
92
|
+
word/word in prose (`commit/tree`, `rer-delegation/1`, `dmsans/v17`): it is
|
|
93
|
+
a repository only if one answers to that name privately, never because none
|
|
94
|
+
does.
|
|
95
|
+
"""
|
|
96
|
+
ambiguous = set()
|
|
79
97
|
repository_text = SERVICE_URL.sub('', text)
|
|
80
98
|
found = {normalize(m.group(1)) for m in URL.finditer(repository_text)
|
|
81
99
|
if not SPONSORS_URL.match(repository_text, m.start())}
|
|
@@ -106,8 +124,16 @@ def references(text, path, local_paths=frozenset(), project_identity=None):
|
|
|
106
124
|
# A plain multi-segment path is ambiguous; GitHub's repository
|
|
107
125
|
# routes, a labeled field or a GitHub command disambiguate it.
|
|
108
126
|
labeled = explicit and (re.search(r'[:=|]', match['separator']) or re.match(r'(?i)github|gh\s', match['label']))
|
|
109
|
-
if
|
|
127
|
+
if REPOSITORY_ROUTE.match(suffix) or labeled:
|
|
110
128
|
found.add(normalize(repository))
|
|
129
|
+
elif not suffix:
|
|
130
|
+
if explicit:
|
|
131
|
+
weak = re.fullmatch(r'(?i)source', match['label']) and not re.search(r'(?i)repository|repo', markdown[match.start():match.start('reference')])
|
|
132
|
+
else:
|
|
133
|
+
line_start = markdown.rfind('\n', 0, match.start()) + 1
|
|
134
|
+
around = markdown[max(line_start, match.start() - CONTEXT_WINDOW):match.end() + CONTEXT_WINDOW].split('\n', 1)[0]
|
|
135
|
+
weak = not NEARBY_CONTEXT.search(around)
|
|
136
|
+
(ambiguous if weak else found).add(normalize(repository))
|
|
111
137
|
elif METADATA.search(path) or SOURCE.search(path):
|
|
112
138
|
if path.lower().endswith('.json'):
|
|
113
139
|
try:
|
|
@@ -146,7 +172,7 @@ def references(text, path, local_paths=frozenset(), project_identity=None):
|
|
|
146
172
|
found.update(normalize(m.group(1)) for m in TYPED_REPOSITORY.finditer(text))
|
|
147
173
|
for declaration in declarations(text):
|
|
148
174
|
found.update(normalize(m.group(1)) for m in DECLARED_SHORTHAND.finditer(declaration))
|
|
149
|
-
return found
|
|
175
|
+
return found, ambiguous - found
|
|
150
176
|
|
|
151
177
|
|
|
152
178
|
def local_dependencies(data, path):
|
|
@@ -29,7 +29,7 @@ regexes = ['''\.(?:test|fixture|fake|example|dummy|placeholder|synthetic)[_-][a-
|
|
|
29
29
|
|
|
30
30
|
# SHA-shaped credentials must not disappear because their random bytes contain
|
|
31
31
|
# an English stopword (for example dead/feed) or fall below generic entropy.
|
|
32
|
-
# Source/module identities still require
|
|
32
|
+
# Source/module identities still require exact structural JSON locations in Python.
|
|
33
33
|
[[rules]]
|
|
34
34
|
id = "hex-api-key"
|
|
35
35
|
description = "Hexadecimal credential assignment"
|
|
@@ -128,7 +128,7 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
|
|
|
128
128
|
if identities[identity_scope].matches(path, hit):
|
|
129
129
|
continue
|
|
130
130
|
else:
|
|
131
|
-
#
|
|
131
|
+
# Identity documents use their own exact structural locations.
|
|
132
132
|
held = working_regular(Path(history.repo), path) if mode == 'dir' else read_regular(path)
|
|
133
133
|
if held and checksums[identity_scope].matches(held[0], hit):
|
|
134
134
|
continue
|
|
@@ -146,9 +146,11 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
|
|
|
146
146
|
scope = 'working-tree' if path in working_paths else 'pre-existing' if commit in history.public_commits else 'newly-public'
|
|
147
147
|
lines.append((count, count + length, path, commit, scope))
|
|
148
148
|
count += length
|
|
149
|
-
for
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
for message_type, commit, data in history.messages():
|
|
150
|
+
path = '@' + message_type + '-message'
|
|
151
|
+
report['history']['tagMessages' if message_type == 'tag' else 'commitMessages'] += 1
|
|
152
|
+
rules, _ = text_rules(data, path, selected_context, resolver, hashes,
|
|
153
|
+
commit_message=message_type == 'commit')
|
|
152
154
|
scope = 'release-tree' if commit == history.commit else 'pre-existing' if commit in history.public_commits else 'newly-public'
|
|
153
155
|
for rule in rules:
|
|
154
156
|
add(rule, path, commit, scope=scope)
|
|
@@ -9,7 +9,9 @@ LOCAL_PATH = re.compile(r'''(?x)(?<![^\s("'`=:\[<>{|])(?:[*_]|~~)*(?:(?:(?i:(?a:
|
|
|
9
9
|
CONFIG = '.publish-check/config.json'
|
|
10
10
|
# An alphabetic top-level label ends before a following digit, such as the DER
|
|
11
11
|
# tag after a certificate's email value; other labels keep the full extent.
|
|
12
|
-
|
|
12
|
+
# A period is punctuation unless it starts another host label; never shorten
|
|
13
|
+
# a suffixed host into a reserved/public domain.
|
|
14
|
+
EMAIL = re.compile(r"(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.(?:[A-Za-z]{2,63}(?![A-Za-z-]|\.[A-Za-z0-9-])|[A-Za-z][A-Za-z0-9-]{1,62}(?![A-Za-z0-9-]|\.[A-Za-z0-9-]))|(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:example|test|invalid)(?![A-Za-z0-9-]|\.[A-Za-z0-9-])", re.I)
|
|
13
15
|
RULES = {'hygiene.local-path', 'hygiene.github-private', 'hygiene.github-missing',
|
|
14
16
|
'hygiene.email', 'hygiene.internal-reference'}
|
|
15
17
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Public-tree and message hygiene; findings never include matched values."""
|
|
2
2
|
from collections import Counter
|
|
3
3
|
import re
|
|
4
|
-
from github_refs import REPOSITORY_HOSTS,
|
|
4
|
+
from github_refs import REPOSITORY_HOSTS, reference_sets
|
|
5
5
|
from destination import RULE as PENDING_DESTINATION
|
|
6
6
|
from hygiene_text import decoded_text
|
|
7
7
|
from provenance import provenance_text
|
|
@@ -9,6 +9,29 @@ from hygiene_config import EMAIL, LOCAL_PATH, public_email
|
|
|
9
9
|
from font_licence import without_required_notices
|
|
10
10
|
from trust_bundle import anchor_values
|
|
11
11
|
URI_AUTHORITY = re.compile(r'''(?i)(?a:(?P<scheme>[a-z][a-z0-9+.-]*:))?//(?P<authority>[^/?#\s<>"']+)''')
|
|
12
|
+
# Match raw, complete commit trailer lines; decoded or embedded prose cannot
|
|
13
|
+
# acquire trailer privileges. Only the address span is omitted from email checks.
|
|
14
|
+
COAUTHOR = re.compile(
|
|
15
|
+
rb'^Co-Authored-By:[ \t]+[^<>\r\n]*[^<>\s][ \t]+<(?P<address>'
|
|
16
|
+
rb'noreply@(?:anthropic|github)\.com|'
|
|
17
|
+
rb'[0-9]+\+[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}@users\.noreply\.github\.com'
|
|
18
|
+
rb')>[ \t]*\r?$', re.I | re.M)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def without_public_coauthor_addresses(data):
|
|
22
|
+
# A trailer block is the final paragraph and contains only trailer lines.
|
|
23
|
+
# A quoted attribution followed by ordinary prose stays ordinary body text.
|
|
24
|
+
tail = data.rstrip(b' \t\r\n')
|
|
25
|
+
block = re.split(rb'\r?\n[ \t]*\r?\n', tail)[-1]
|
|
26
|
+
if not all(re.fullmatch(rb'[A-Za-z0-9-]+:[ \t]+[^\r\n]+\r?', line)
|
|
27
|
+
for line in block.split(b'\n')):
|
|
28
|
+
return data
|
|
29
|
+
offset = len(tail) - len(block)
|
|
30
|
+
result = bytearray(data)
|
|
31
|
+
for match in COAUTHOR.finditer(block):
|
|
32
|
+
start, end = match.span('address')
|
|
33
|
+
result[offset + start:offset + end] = b' ' * (end - start)
|
|
34
|
+
return bytes(result)
|
|
12
35
|
|
|
13
36
|
|
|
14
37
|
def email_userinfo(match):
|
|
@@ -25,12 +48,35 @@ def private_emails(text, config):
|
|
|
25
48
|
# URL paths, queries and fragments remain visible to email inspection.
|
|
26
49
|
email_text = URI_AUTHORITY.sub(email_userinfo, without_required_notices(text))
|
|
27
50
|
email_text = re.sub(r'(?i)\b(?a:git@github\.com:)(?=[A-Za-z0-9])', '', email_text)
|
|
28
|
-
version
|
|
51
|
+
# A package manager's patch filename joins the name and version with an at sign.
|
|
52
|
+
version = r'(?:v?[0-9]+(?:\.[0-9]+)*(?:-[A-Za-z0-9-]+)?|[a-f0-9]{40})(?:\.patch)?(?![A-Za-z0-9_.-])'
|
|
29
53
|
email_text = re.sub(r'(?m)^\s*-?\s*uses:\s*[\"\']?[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[^\s\"\']+', '', email_text)
|
|
30
54
|
email_text = re.sub(r'(?<![A-Za-z0-9])(?:@[A-Za-z0-9_.-]+/)?[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@' + version, '', email_text)
|
|
31
55
|
return [match.group() for match in EMAIL.finditer(email_text) if not public_email(match.group(), config)]
|
|
32
56
|
|
|
33
57
|
|
|
58
|
+
# A stored host name is lower case, upper case or capitalised, label by label.
|
|
59
|
+
# Bytes that merely decode to text, such as compressed pixels, rarely are.
|
|
60
|
+
HOST_LABEL = re.compile(r'(?:[a-z0-9-]+|[A-Z0-9-]+|[A-Z][a-z0-9-]*)')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Control bytes that no text encoding uses (tab, newline, form feed and carriage
|
|
64
|
+
# return are text). Legacy 8-bit text such as latin-1 or Windows-1252 has none,
|
|
65
|
+
# while compressed or packed data is about one byte in nine.
|
|
66
|
+
NON_TEXT = re.compile(rb'[\x01-\x08\x0b\x0e-\x1f\x7f]')
|
|
67
|
+
BINARY_SHARE = 0.05
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def binary(data):
|
|
71
|
+
# NULs are not counted: UTF-16 and UTF-32 text carries them between letters.
|
|
72
|
+
text = data.replace(b'\0', b'')
|
|
73
|
+
return bool(text) and len(NON_TEXT.findall(text)) / len(text) > BINARY_SHARE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def stored_host(address):
|
|
77
|
+
return all(HOST_LABEL.fullmatch(label) for label in address.rsplit('@', 1)[1].split('.'))
|
|
78
|
+
|
|
79
|
+
|
|
34
80
|
def certificate_metadata(data, bundle, emails, config):
|
|
35
81
|
# Every match lies inside a verified trust-anchor subject value: none
|
|
36
82
|
# remains once those values are blanked, and each is one of theirs.
|
|
@@ -46,11 +92,11 @@ def certificate_metadata(data, bundle, emails, config):
|
|
|
46
92
|
return not Counter(emails) - inside
|
|
47
93
|
|
|
48
94
|
|
|
49
|
-
def inspect(data, path, config, resolver):
|
|
50
|
-
return inspect_text(data, path, config, resolver)
|
|
95
|
+
def inspect(data, path, config, resolver, *, commit_message=False):
|
|
96
|
+
return inspect_text(data, path, config, resolver, commit_message=commit_message)
|
|
51
97
|
|
|
52
98
|
|
|
53
|
-
def inspect_text(data, path, config, resolver):
|
|
99
|
+
def inspect_text(data, path, config, resolver, *, commit_message=False):
|
|
54
100
|
# NUL removal covers UTF-16/32 ASCII content, matching the secret scanner.
|
|
55
101
|
text = decoded_text(data)
|
|
56
102
|
rules = set()
|
|
@@ -59,18 +105,27 @@ def inspect_text(data, path, config, resolver):
|
|
|
59
105
|
if any(pattern.search(text) for pattern in config['internalReferencePatterns']):
|
|
60
106
|
rules.add('hygiene.internal-reference')
|
|
61
107
|
if '@' in text:
|
|
62
|
-
|
|
108
|
+
email_text = decoded_text(without_public_coauthor_addresses(data)) if commit_message else text
|
|
109
|
+
emails = private_emails(email_text, config)
|
|
63
110
|
bundle = config.get('trustBundles', {}).get(path)
|
|
64
111
|
if emails and not (bundle and certificate_metadata(data, bundle, emails, config)):
|
|
65
|
-
|
|
112
|
+
# Inside binary data a mailbox is judged by its own shape: a host
|
|
113
|
+
# name with mixed-case letters inside a label is not one.
|
|
114
|
+
if not binary(data) or any(stored_host(email) for email in emails):
|
|
115
|
+
rules.add('hygiene.email')
|
|
66
116
|
ref_text, provenance = provenance_text(text, path, config.get('declaredSources', frozenset()))
|
|
67
|
-
|
|
117
|
+
stated, ambiguous = reference_sets(ref_text, path, config.get('localPaths', frozenset()), config.get('projectIdentity'))
|
|
118
|
+
for repository in sorted(stated | ambiguous):
|
|
68
119
|
# A future source identity does not assert current GitHub visibility.
|
|
69
120
|
# Explicit release destinations still take the strict visibility path.
|
|
70
121
|
if (repository in config.get('intendedPublicRepositories', frozenset()) and
|
|
71
122
|
repository != config.get('pendingDestination')):
|
|
72
123
|
continue
|
|
73
124
|
state = resolver.resolve(repository)
|
|
125
|
+
# A bare word/word with no GitHub context names a repository only when
|
|
126
|
+
# one exists privately; that no repository answers to it proves nothing.
|
|
127
|
+
if state == 'missing' and repository in ambiguous and repository != config.get('pendingDestination'):
|
|
128
|
+
continue
|
|
74
129
|
if state == 'missing' and repository == config.get('pendingDestination'):
|
|
75
130
|
rules.add(PENDING_DESTINATION)
|
|
76
131
|
elif state in ('private', 'missing'):
|
|
@@ -7,6 +7,21 @@
|
|
|
7
7
|
{ "repository": "just-every/lamp", "reason": "Owner-declared public identity; created by clean-history export at the first public tag." },
|
|
8
8
|
{ "repository": "just-every/dial", "reason": "Owner-declared public identity; created by clean-history export at the first public tag." },
|
|
9
9
|
{ "repository": "just-every/orbit", "reason": "Owner-declared public identity; created by clean-history export at the first public tag." },
|
|
10
|
-
{ "repository": "just-every/uptime-monitor", "reason": "Owner-declared public identity; created by clean-history export at the first public tag." }
|
|
10
|
+
{ "repository": "just-every/uptime-monitor", "reason": "Owner-declared public identity; created by clean-history export at the first public tag." },
|
|
11
|
+
{ "repository": "just-every/run-slate", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
12
|
+
{ "repository": "just-every/run-pencil", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
13
|
+
{ "repository": "just-every/run-lamp", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
14
|
+
{ "repository": "just-every/run-uptime", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
15
|
+
{ "repository": "just-every/run-cradle", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
16
|
+
{ "repository": "just-every/run-vector", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
17
|
+
{ "repository": "just-every/run-mango", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
18
|
+
{ "repository": "just-every/run-dial", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
19
|
+
{ "repository": "just-every/run-penny", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
20
|
+
{ "repository": "just-every/run-nook", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
21
|
+
{ "repository": "just-every/run-orbit", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
22
|
+
{ "repository": "just-every/run-oncall", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
23
|
+
{ "repository": "just-every/run-spool", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
24
|
+
{ "repository": "just-every/run-happytohelp", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
25
|
+
{ "repository": "just-every/run-auth", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." }
|
|
11
26
|
]
|
|
12
27
|
}
|