@ran-sh/dsh-crew 2.1.6 → 2.1.8
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/.claude-plugin/plugin.json +1 -1
- package/docs/readiness-matrix.md +19 -0
- package/package.json +1 -1
- package/src/ci-evidence.mjs +57 -23
- package/src/runtime-identity.mjs +1 -1
- package/src/workflow-runtime.mjs +27 -6
package/docs/readiness-matrix.md
CHANGED
|
@@ -83,3 +83,22 @@ An evidence record may contain:
|
|
|
83
83
|
```
|
|
84
84
|
|
|
85
85
|
The matrix does not fetch or trust arbitrary remote data by itself. Loading and authenticating an evidence source is the responsibility of the higher layer that calls the builder.
|
|
86
|
+
|
|
87
|
+
`src/ci-evidence.mjs` is that layer for the `ci` rows. It resolves the running
|
|
88
|
+
version's tag to the commit it was cut from, reads that commit's CI run, and
|
|
89
|
+
evidences each platform row from its own job. Three things have to hold before a
|
|
90
|
+
row may go green, because each is a way to look validated without being
|
|
91
|
+
validated: the job's runner labels must name the platform the row claims (a job
|
|
92
|
+
*name* is not a platform), the run must be one this repository pushed (a pull
|
|
93
|
+
request carries its own workflow file, so its runs are not evidence), and the
|
|
94
|
+
job itself must have concluded successfully. The commit is recorded in
|
|
95
|
+
`evidence_ref` so `version → tag → commit` stays auditable. Nothing is read from
|
|
96
|
+
or written to credentials: the public API is used anonymously.
|
|
97
|
+
|
|
98
|
+
One limit belongs where a reader of a green row will meet it. The mapping starts
|
|
99
|
+
from the version number, so the evidence describes the commit the matching tag
|
|
100
|
+
points at. A payload installed with `update --candidate <dir>` can carry local
|
|
101
|
+
edits that were never tagged while reporting the same version, and the row is
|
|
102
|
+
then green for the tagged commit rather than for the tree that is running.
|
|
103
|
+
`evidence_ref` names that commit, which is as much as this can honestly say
|
|
104
|
+
until the payload records its own revision.
|
package/package.json
CHANGED
package/src/ci-evidence.mjs
CHANGED
|
@@ -8,10 +8,21 @@
|
|
|
8
8
|
// network.
|
|
9
9
|
//
|
|
10
10
|
// It fails closed in every direction. A version with no tag, a tag whose commit
|
|
11
|
-
// has no run, a run
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// has no run, a run this repository did not push, a missing platform job, a job
|
|
12
|
+
// that did not pass, a job whose runner is not the platform its row claims, a
|
|
13
|
+
// timeout, an HTTP error — all of them return no evidence at all, which leaves
|
|
14
|
+
// the CI rows NOT_RUN. Promoting a row on anything less than a green run at the
|
|
15
|
+
// exact commit being validated is the one failure this module exists to avoid.
|
|
16
|
+
//
|
|
17
|
+
// The mapping's limit, stated plainly: evidence is resolved *from the version
|
|
18
|
+
// number*, so it describes the commit the matching tag points at. `dsh-crew
|
|
19
|
+
// update --candidate <dir>` can install a tree that was never published while
|
|
20
|
+
// reporting the same version — its local edits are not tagged, so this module
|
|
21
|
+
// would present the tag's commit as the validated one. `evidence_ref` carries
|
|
22
|
+
// that commit so a reader can see which commit the green run covered, but it
|
|
23
|
+
// cannot see the running tree, and nothing here may claim otherwise. Widening
|
|
24
|
+
// this would need the running tree's revision, which the payload does not
|
|
25
|
+
// currently record.
|
|
15
26
|
//
|
|
16
27
|
// Authentication is optional and never required: the repository is public, so
|
|
17
28
|
// the anonymous API answers. A token is used only when the caller already has
|
|
@@ -23,6 +34,10 @@ export const CI_EVIDENCE_REPO = 'Ran-sh/dsh-crew';
|
|
|
23
34
|
// The row each CI job validates. A platform with no job in the workflow cannot
|
|
24
35
|
// be evidenced by any run, so `macos_smoke` is deliberately absent rather than
|
|
25
36
|
// listed and always missing.
|
|
37
|
+
// `runner` is a gate, not a label. Matching on the job name alone would let a
|
|
38
|
+
// job called `deterministic` that ran on a macOS runner evidence the *linux*
|
|
39
|
+
// row, which is exactly the "looks validated, isn't" the matrix exists to
|
|
40
|
+
// refuse; the job's own labels have to name the platform the row claims.
|
|
26
41
|
const PLATFORM_JOBS = [
|
|
27
42
|
{ row: 'linux_deterministic', job: 'deterministic', runner: 'ubuntu' },
|
|
28
43
|
{ row: 'windows_regressions', job: 'windows-paths', runner: 'windows' },
|
|
@@ -30,6 +45,10 @@ const PLATFORM_JOBS = [
|
|
|
30
45
|
];
|
|
31
46
|
|
|
32
47
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
48
|
+
// Negative results expire sooner. Caching "nothing proven" for the full window
|
|
49
|
+
// would let one transient network hiccup freeze a row at NOT_RUN for ten
|
|
50
|
+
// minutes, and the cost of asking again is bounded by the deadline.
|
|
51
|
+
const NEGATIVE_CACHE_TTL_MS = 60 * 1000;
|
|
33
52
|
const cache = new Map();
|
|
34
53
|
|
|
35
54
|
/** Test seam: drop memoized evidence between cases. */
|
|
@@ -55,8 +74,8 @@ function apiHeaders(token) {
|
|
|
55
74
|
// route must not depend on the transport cooperating, so the request is also
|
|
56
75
|
// raced against the clock. A fetch that ignores its signal then returns nothing
|
|
57
76
|
// at the deadline instead of holding the route open.
|
|
58
|
-
async function getJson(fetchImpl, url, { token, deadline }) {
|
|
59
|
-
const remaining = deadline -
|
|
77
|
+
async function getJson(fetchImpl, url, { token, deadline, now }) {
|
|
78
|
+
const remaining = deadline - now();
|
|
60
79
|
if (remaining <= 0) return null;
|
|
61
80
|
const controller = new AbortController();
|
|
62
81
|
let timer;
|
|
@@ -83,14 +102,14 @@ async function getJson(fetchImpl, url, { token, deadline }) {
|
|
|
83
102
|
* version with no tag resolves to null and no evidence is produced: the running
|
|
84
103
|
* code is then not the code CI validated, and saying so is the honest answer.
|
|
85
104
|
*/
|
|
86
|
-
async function resolveVersionCommit(fetchImpl, { repo, version, token, deadline }) {
|
|
105
|
+
async function resolveVersionCommit(fetchImpl, { repo, version, token, deadline, now }) {
|
|
87
106
|
const tag = `v${version}`;
|
|
88
|
-
const ref = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/git/ref/tags/${encodeURIComponent(tag)}`, { token, deadline });
|
|
107
|
+
const ref = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/git/ref/tags/${encodeURIComponent(tag)}`, { token, deadline, now });
|
|
89
108
|
const object = ref?.object;
|
|
90
109
|
if (!object) return null;
|
|
91
110
|
if (object.type === 'commit' && typeof object.sha === 'string') return object.sha;
|
|
92
111
|
if (object.type === 'tag' && typeof object.sha === 'string') {
|
|
93
|
-
const annotated = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/git/tags/${object.sha}`, { token, deadline });
|
|
112
|
+
const annotated = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/git/tags/${object.sha}`, { token, deadline, now });
|
|
94
113
|
const sha = annotated?.object?.sha;
|
|
95
114
|
return typeof sha === 'string' ? sha : null;
|
|
96
115
|
}
|
|
@@ -103,7 +122,11 @@ function evidenceFor(jobs, { runId, sha }) {
|
|
|
103
122
|
const match = jobs.find((entry) => entry?.name === job);
|
|
104
123
|
if (!match) continue;
|
|
105
124
|
if (String(match.conclusion ?? '').toLowerCase() !== 'success') continue;
|
|
106
|
-
|
|
125
|
+
// The job's labels must name the platform the row claims. A name is not a
|
|
126
|
+
// platform, and a runner label that does not match is treated as no
|
|
127
|
+
// evidence at all rather than as evidence for the wrong row.
|
|
128
|
+
const labels = Array.isArray(match.labels) ? match.labels : [];
|
|
129
|
+
if (!labels.some((label) => String(label).toLowerCase().includes(runner))) continue;
|
|
107
130
|
evidence[row] = {
|
|
108
131
|
status: 'PASS',
|
|
109
132
|
reason_code: 'CI_GREEN',
|
|
@@ -111,7 +134,7 @@ function evidenceFor(jobs, { runId, sha }) {
|
|
|
111
134
|
// The commit is part of the reference on purpose: version -> tag -> commit
|
|
112
135
|
// is a mapping, and the reader has to be able to audit which commit the
|
|
113
136
|
// green run actually covered.
|
|
114
|
-
evidence_ref: `run-${runId}/${job}/${runner}@${String(sha).slice(0, 12)}${labels ? ` (${labels})` : ''}`,
|
|
137
|
+
evidence_ref: `run-${runId}/${job}/${runner}@${String(sha).slice(0, 12)}${labels.length ? ` (${labels.join(',')})` : ''}`,
|
|
115
138
|
};
|
|
116
139
|
}
|
|
117
140
|
return evidence;
|
|
@@ -133,30 +156,41 @@ export async function loadCiEvidence({
|
|
|
133
156
|
if (typeof version !== 'string' || version.trim() === '') return {};
|
|
134
157
|
if (typeof fetchImpl !== 'function') return {};
|
|
135
158
|
|
|
136
|
-
|
|
159
|
+
// The credential is part of the key: an anonymous result produced under a
|
|
160
|
+
// rate limit must not be served to a call that offered a token.
|
|
161
|
+
const key = `${repo}@${version.trim()}@${typeof token === 'string' && token.trim() !== '' ? 'auth' : 'anon'}`;
|
|
137
162
|
const hit = cache.get(key);
|
|
138
|
-
if (useCache && hit
|
|
163
|
+
if (useCache && hit) {
|
|
164
|
+
const ttl = Object.keys(hit.evidence).length > 0 ? CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS;
|
|
165
|
+
if (now() - hit.at < ttl) return hit.evidence;
|
|
166
|
+
}
|
|
139
167
|
|
|
140
168
|
const deadline = now() + timeoutMs;
|
|
141
|
-
const evidence = await loadUncached({ repo, version: version.trim(), token, fetchImpl, deadline });
|
|
169
|
+
const evidence = await loadUncached({ repo, version: version.trim(), token, fetchImpl, deadline, now });
|
|
142
170
|
cache.set(key, { at: now(), evidence });
|
|
143
171
|
return evidence;
|
|
144
172
|
}
|
|
145
173
|
|
|
146
|
-
async function loadUncached({ repo, version, token, fetchImpl, deadline }) {
|
|
147
|
-
const sha = await resolveVersionCommit(fetchImpl, { repo, version, token, deadline });
|
|
174
|
+
async function loadUncached({ repo, version, token, fetchImpl, deadline, now }) {
|
|
175
|
+
const sha = await resolveVersionCommit(fetchImpl, { repo, version, token, deadline, now });
|
|
148
176
|
if (!sha) return {};
|
|
149
177
|
|
|
150
|
-
const runs = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/actions/runs?head_sha=${sha}&per_page=20`, { token, deadline });
|
|
178
|
+
const runs = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/actions/runs?head_sha=${sha}&per_page=20`, { token, deadline, now });
|
|
151
179
|
const list = Array.isArray(runs?.workflow_runs) ? runs.workflow_runs : [];
|
|
152
|
-
// The newest CI run for this commit, and each row judged by its own job.
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
180
|
+
// The newest CI run for this commit, and each row judged by its own job.
|
|
181
|
+
//
|
|
182
|
+
// The run's overall conclusion is deliberately not a gate: one platform's red
|
|
183
|
+
// job must not withdraw another platform's evidence, and each row asks only
|
|
184
|
+
// whether *its* validation ran and passed. The run's *identity* is a gate
|
|
185
|
+
// though. A workflow is matched by name, and a name is not an identity — a
|
|
186
|
+
// pull request can carry a workflow file of its own, so a run this repository
|
|
187
|
+
// did not push, or one belonging to a fork, is not accepted as evidence.
|
|
188
|
+
const ciRun = list.find((run) => run?.name === 'CI'
|
|
189
|
+
&& String(run?.event ?? '').toLowerCase() === 'push'
|
|
190
|
+
&& run?.head_repository?.full_name === repo);
|
|
157
191
|
if (!ciRun) return {};
|
|
158
192
|
|
|
159
|
-
const jobsBody = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/actions/runs/${ciRun.id}/jobs?per_page=50`, { token, deadline });
|
|
193
|
+
const jobsBody = await getJson(fetchImpl, `https://api.github.com/repos/${repo}/actions/runs/${ciRun.id}/jobs?per_page=50`, { token, deadline, now });
|
|
160
194
|
const jobs = Array.isArray(jobsBody?.jobs) ? jobsBody.jobs : [];
|
|
161
195
|
return evidenceFor(jobs, { runId: ciRun.id, sha });
|
|
162
196
|
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export {
|
|
|
36
36
|
// included in the identity contract.
|
|
37
37
|
const RUNTIME_ID = randomUUID();
|
|
38
38
|
|
|
39
|
-
export const RUNTIME_VERSION = '2.1.
|
|
39
|
+
export const RUNTIME_VERSION = '2.1.8';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|
package/src/workflow-runtime.mjs
CHANGED
|
@@ -408,9 +408,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
408
408
|
source: job.source,
|
|
409
409
|
model_class_hint: job.model_class_hint,
|
|
410
410
|
escalation_reason: escalationReason,
|
|
411
|
-
onAttemptStarted: (actualId) =>
|
|
412
|
-
if (typeof actualId === 'string' && actualId) job.current_attempt_id = actualId;
|
|
413
|
-
},
|
|
411
|
+
onAttemptStarted: (actualId) => adoptAttemptId(job, actualId),
|
|
414
412
|
});
|
|
415
413
|
job.current_attempt_id = null;
|
|
416
414
|
if (job.cancelling) { await cancelWorkflow(job); return; }
|
|
@@ -602,9 +600,7 @@ export function createWorkflowRuntime(adapters, {
|
|
|
602
600
|
source: job.source,
|
|
603
601
|
model_class_hint: 'pro',
|
|
604
602
|
escalation_reason: null,
|
|
605
|
-
onAttemptStarted: (actualId) =>
|
|
606
|
-
if (typeof actualId === 'string' && actualId) job.current_attempt_id = actualId;
|
|
607
|
-
},
|
|
603
|
+
onAttemptStarted: (actualId) => adoptAttemptId(job, actualId),
|
|
608
604
|
});
|
|
609
605
|
job.current_attempt_id = null;
|
|
610
606
|
const attemptView = { ...attemptRecord(ar, 0), phase: 'review' };
|
|
@@ -665,6 +661,31 @@ export function createWorkflowRuntime(adapters, {
|
|
|
665
661
|
: `${workflowId}-a${suffix || '0'}`;
|
|
666
662
|
}
|
|
667
663
|
|
|
664
|
+
/**
|
|
665
|
+
* Record the id the executor actually started, and honour a cancel that
|
|
666
|
+
* arrived while it was still being dispatched.
|
|
667
|
+
*
|
|
668
|
+
* `cancelWorkflow` can only stop what `current_attempt_id` names. During
|
|
669
|
+
* dispatch that is the workflow-scoped placeholder, which the transport does
|
|
670
|
+
* not recognise as a runnable attempt, so a cancel landing in that window
|
|
671
|
+
* stops nothing — and the job the executor starts a moment later runs to
|
|
672
|
+
* completion while the workflow reports itself cancelled. Re-cancelling after
|
|
673
|
+
* the attempt returns does not help: `cancelWorkflow` returns the promise it
|
|
674
|
+
* already made. This is the one moment the real id becomes known, so the
|
|
675
|
+
* cancel that already happened is applied here.
|
|
676
|
+
*/
|
|
677
|
+
function adoptAttemptId(job, actualId) {
|
|
678
|
+
if (typeof actualId !== 'string' || actualId === '') return;
|
|
679
|
+
job.current_attempt_id = actualId;
|
|
680
|
+
if (!job.cancelling) return;
|
|
681
|
+
try {
|
|
682
|
+
Promise.resolve(adapters.cancelAttempt?.(actualId)).catch(() => {});
|
|
683
|
+
} catch {
|
|
684
|
+
// A transport that throws synchronously is still a cancel that did not
|
|
685
|
+
// land; the workflow's own terminal state is unchanged by it.
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
668
689
|
function cancelWorkflow(job) {
|
|
669
690
|
if (job.cancelPromise) return job.cancelPromise;
|
|
670
691
|
if (job.status !== 'running') return;
|