@apex-actions/cli 0.67.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/LICENSE +20 -0
- package/README.md +84 -0
- package/THIRD-PARTY-NOTICES.md +1226 -0
- package/dist/cli/actions/main.js +193 -0
- package/dist/cli/approval/main.js +76 -0
- package/dist/cli/artifact/main.js +115 -0
- package/dist/cli/cache/main.js +104 -0
- package/dist/cli/deployment/main.js +73 -0
- package/dist/cli/dispatch/main.js +47 -0
- package/dist/cli/environment/main.js +139 -0
- package/dist/cli/login/main.js +73 -0
- package/dist/cli/logout/main.js +43 -0
- package/dist/cli/logs/main.js +125 -0
- package/dist/cli/lsp/main.js +46 -0
- package/dist/cli/main.js +88 -0
- package/dist/cli/migrate/main.js +275 -0
- package/dist/cli/notify/main.js +257 -0
- package/dist/cli/oidc/main.js +103 -0
- package/dist/cli/reproduce/main.js +73 -0
- package/dist/cli/rerun/main.js +100 -0
- package/dist/cli/run/main.js +50 -0
- package/dist/cli/runner/main.js +238 -0
- package/dist/cli/schedule/main.js +54 -0
- package/dist/cli/search/main.js +123 -0
- package/dist/cli/secret/main.js +102 -0
- package/dist/cli/secret/shared.js +104 -0
- package/dist/cli/spend/main.js +81 -0
- package/dist/cli/terms/main.js +85 -0
- package/dist/cli/tests/main.js +122 -0
- package/dist/cli/timing/main.js +90 -0
- package/dist/cli/validate/main.js +48 -0
- package/dist/cli/variable/main.js +83 -0
- package/dist/cli/whoami/main.js +63 -0
- package/dist/cli/why/main.js +106 -0
- package/dist/src/application/authenticate.js +56 -0
- package/dist/src/application/dispatch-workflow.js +23 -0
- package/dist/src/application/explain-runs.js +40 -0
- package/dist/src/application/index.js +2 -0
- package/dist/src/application/inspect-runs.js +29 -0
- package/dist/src/application/manage-actions.js +26 -0
- package/dist/src/application/manage-approvals.js +24 -0
- package/dist/src/application/manage-artifacts.js +79 -0
- package/dist/src/application/manage-caches.js +41 -0
- package/dist/src/application/manage-environments.js +136 -0
- package/dist/src/application/manage-notifications.js +20 -0
- package/dist/src/application/manage-runners.js +89 -0
- package/dist/src/application/manage-secrets.js +83 -0
- package/dist/src/application/manage-terms.js +34 -0
- package/dist/src/application/read-logs.js +40 -0
- package/dist/src/application/report-spend.js +15 -0
- package/dist/src/application/rerun-runs.js +34 -0
- package/dist/src/application/resolve-run.js +19 -0
- package/dist/src/application/run-workflow.js +17 -0
- package/dist/src/application/scan-migration.js +76 -0
- package/dist/src/application/search-logs.js +24 -0
- package/dist/src/application/serve-language-server.js +15 -0
- package/dist/src/application/validate-workflows.js +12 -0
- package/dist/src/domain/credentials.js +82 -0
- package/dist/src/domain/fleet-history.js +136 -0
- package/dist/src/domain/index.js +2 -0
- package/dist/src/domain/insight.js +42 -0
- package/dist/src/domain/logsearch.js +40 -0
- package/dist/src/domain/migration.js +122 -0
- package/dist/src/domain/notify.js +64 -0
- package/dist/src/domain/outcomediff.js +40 -0
- package/dist/src/domain/provenance.js +63 -0
- package/dist/src/domain/runs.js +90 -0
- package/dist/src/domain/secrets.js +39 -0
- package/dist/src/domain/spend.js +50 -0
- package/dist/src/domain/supplychain.js +48 -0
- package/dist/src/domain/terms.js +16 -0
- package/dist/src/domain/validation.js +4 -0
- package/dist/src/infrastructure/control-plane-http.js +458 -0
- package/dist/src/infrastructure/credentials.js +134 -0
- package/dist/src/infrastructure/engine-binary.js +164 -0
- package/dist/src/infrastructure/github-api.js +154 -0
- package/dist/src/infrastructure/index.js +2 -0
- package/dist/src/infrastructure/loopback-login.js +136 -0
- package/dist/src/infrastructure/runner-binary.js +74 -0
- package/dist/src/infrastructure/secret-input.js +52 -0
- package/package.json +54 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// domain layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
/**
|
|
3
|
+
* A customer's terminal credential, and the browser login that mints one
|
|
4
|
+
* (control-plane ADR-0064).
|
|
5
|
+
*
|
|
6
|
+
* Pure decisions only: where the consent page is, whether a loopback callback
|
|
7
|
+
* is the one this login asked for, whether a file mode is private enough to
|
|
8
|
+
* hold a token. The randomness, the socket and the file live in
|
|
9
|
+
* infrastructure.
|
|
10
|
+
*/
|
|
11
|
+
/** Where the CLI goes when nobody says otherwise: the hosted control plane. */
|
|
12
|
+
export const DEFAULT_API_URL = 'https://app.apexactions.com';
|
|
13
|
+
/** The dashboard page that lists, mints and revokes terminal tokens. */
|
|
14
|
+
export const TOKENS_PAGE = '/settings/tokens';
|
|
15
|
+
export const describeSource = (source) => {
|
|
16
|
+
switch (source) {
|
|
17
|
+
case 'env':
|
|
18
|
+
return 'APEX_TOKEN in the environment';
|
|
19
|
+
case 'login':
|
|
20
|
+
return 'the token `apex login` saved';
|
|
21
|
+
case 'operator':
|
|
22
|
+
return 'APEX_ADMIN_TOKEN, the operator token';
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** A URL without its trailing slash, so two spellings of one origin compare equal. */
|
|
26
|
+
export const normaliseUrl = (url) => url.replace(/\/+$/, '');
|
|
27
|
+
/**
|
|
28
|
+
* The web app's base URL. The consent page lives there, not in the API; on the
|
|
29
|
+
* hosted service one origin serves both, so the API's is the right default.
|
|
30
|
+
*/
|
|
31
|
+
export const webBaseFor = (apiUrl, webUrl) => normaliseUrl(webUrl !== undefined && webUrl !== '' ? webUrl : apiUrl);
|
|
32
|
+
/**
|
|
33
|
+
* `ssh`'s rule: a file holding a credential that anybody but its owner can
|
|
34
|
+
* read is refused, not used. The group and other bits must all be clear.
|
|
35
|
+
*/
|
|
36
|
+
export const isPrivateMode = (mode) => (mode & 0o077) === 0;
|
|
37
|
+
/** Whether a saved credential has run out, so it is not offered to the server. */
|
|
38
|
+
export const hasExpired = (credential, now) => {
|
|
39
|
+
const at = Date.parse(credential.expiresAt);
|
|
40
|
+
return Number.isFinite(at) && at <= now.getTime();
|
|
41
|
+
};
|
|
42
|
+
/** A parsed credentials file, or undefined when it is not one — never a partial. */
|
|
43
|
+
export const parseSavedCredential = (raw) => {
|
|
44
|
+
if (raw === null || typeof raw !== 'object')
|
|
45
|
+
return undefined;
|
|
46
|
+
const r = raw;
|
|
47
|
+
const fields = ['token', 'login', 'apiUrl', 'expiresAt'];
|
|
48
|
+
for (const f of fields)
|
|
49
|
+
if (typeof r[f] !== 'string' || r[f] === '')
|
|
50
|
+
return undefined;
|
|
51
|
+
return { token: r['token'], login: r['login'], apiUrl: r['apiUrl'], expiresAt: r['expiresAt'] };
|
|
52
|
+
};
|
|
53
|
+
/** The consent page `apex login` opens (RFC 8252 loopback, RFC 7636 S256). */
|
|
54
|
+
export const authorizeUrl = (webBase, request) => {
|
|
55
|
+
const query = new URLSearchParams({
|
|
56
|
+
port: String(request.port),
|
|
57
|
+
state: request.state,
|
|
58
|
+
code_challenge: request.codeChallenge,
|
|
59
|
+
name: request.name,
|
|
60
|
+
});
|
|
61
|
+
return `${normaliseUrl(webBase)}/cli/authorize?${query.toString()}`;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Judge what arrived at the loopback callback.
|
|
65
|
+
*
|
|
66
|
+
* The `state` is checked before anything else, the denial included: a request
|
|
67
|
+
* this login did not cause — another page on this machine guessing the port —
|
|
68
|
+
* must not be able to end it with a message of its choosing either.
|
|
69
|
+
*/
|
|
70
|
+
export const judgeCallback = (params, expectedState) => {
|
|
71
|
+
if (params.get('state') !== expectedState)
|
|
72
|
+
return { kind: 'state-mismatch' };
|
|
73
|
+
const error = params.get('error');
|
|
74
|
+
if (error !== null && error !== '') {
|
|
75
|
+
const description = params.get('error_description');
|
|
76
|
+
return { kind: 'denied', detail: description !== null && description !== '' ? `${error}: ${description}` : error };
|
|
77
|
+
}
|
|
78
|
+
const code = params.get('code');
|
|
79
|
+
if (code === null || code === '')
|
|
80
|
+
return { kind: 'malformed' };
|
|
81
|
+
return { kind: 'code', code };
|
|
82
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// domain layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
//
|
|
3
|
+
// How big was the fleet, and when was it nothing?
|
|
4
|
+
//
|
|
5
|
+
// The control plane serves runner *lifetimes* — `registeredAt` to `lastSeenAt`,
|
|
6
|
+
// departed runners included (control-plane ADR-0041). Everything below is the
|
|
7
|
+
// pure arithmetic that turns a pile of intervals into the two answers a capacity
|
|
8
|
+
// question actually wants: the peak, and the time at zero. It is here rather
|
|
9
|
+
// than in the control plane so that it can be tested without a server, and so
|
|
10
|
+
// the endpoint stays a statement of fact rather than of judgement.
|
|
11
|
+
//
|
|
12
|
+
// **What this can and cannot say.** A runner touches its row on every long-poll,
|
|
13
|
+
// at most `POLL_WINDOW_SECONDS` apart, so each interval's end is right to within
|
|
14
|
+
// that. It is therefore honest about an idle night — the question is whether the
|
|
15
|
+
// count was zero for hours — and dishonest about anything measured in seconds.
|
|
16
|
+
/** The control plane's cap on the acquire long-poll: the resolution of an end. */
|
|
17
|
+
export const POLL_WINDOW_SECONDS = 30;
|
|
18
|
+
/**
|
|
19
|
+
* The end of a runner's life.
|
|
20
|
+
*
|
|
21
|
+
* A runner that registered and never polled ends where it began — the same
|
|
22
|
+
* COALESCE the control plane reaps by. A runner still alive has no end inside
|
|
23
|
+
* the window, so it is clipped to `until` rather than treated as departed:
|
|
24
|
+
* `lastSeenAt` on a live runner is "a moment ago", not "gone since".
|
|
25
|
+
*/
|
|
26
|
+
const endOf = (r, until) => {
|
|
27
|
+
const last = r.lastSeenAt === undefined ? Date.parse(r.registeredAt) : Date.parse(r.lastSeenAt);
|
|
28
|
+
return r.deleted ? last : Math.max(last, until);
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Reconstruct the fleet's size across the window.
|
|
32
|
+
*
|
|
33
|
+
* **Only pooled runners count.** A runner without a pool is not fleet capacity —
|
|
34
|
+
* on this deployment it is the dogfooding runner on the control-plane host, which
|
|
35
|
+
* never goes away and would make "the fleet was at zero" impossible to ever
|
|
36
|
+
* observe. They are reported as excluded rather than dropped in silence, because
|
|
37
|
+
* a count that quietly ignores machines is the kind of number that gets believed.
|
|
38
|
+
*/
|
|
39
|
+
export const reconstructFleet = (runners, window) => {
|
|
40
|
+
const since = Date.parse(window.since);
|
|
41
|
+
const until = Date.parse(window.until);
|
|
42
|
+
const counted = [];
|
|
43
|
+
const excluded = [];
|
|
44
|
+
for (const r of runners) {
|
|
45
|
+
if (r.pool === undefined || r.pool === '') {
|
|
46
|
+
excluded.push({ name: r.name, reason: 'no pool — not fleet capacity' });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
counted.push(r);
|
|
50
|
+
}
|
|
51
|
+
const edges = [];
|
|
52
|
+
for (const r of counted) {
|
|
53
|
+
const from = Math.max(Date.parse(r.registeredAt), since);
|
|
54
|
+
const to = Math.min(endOf(r, until), until);
|
|
55
|
+
if (to <= from)
|
|
56
|
+
continue; // lived entirely outside the window after clipping
|
|
57
|
+
edges.push({ at: from, delta: +1, pool: r.pool });
|
|
58
|
+
edges.push({ at: to, delta: -1, pool: r.pool });
|
|
59
|
+
}
|
|
60
|
+
// Departures before arrivals at the same instant: an instance replaced at a
|
|
61
|
+
// tick boundary must not be counted as two.
|
|
62
|
+
edges.sort((a, b) => a.at - b.at || a.delta - b.delta);
|
|
63
|
+
let count = 0;
|
|
64
|
+
let peak = { count: 0, at: window.since };
|
|
65
|
+
const perPool = new Map();
|
|
66
|
+
const poolPeak = new Map();
|
|
67
|
+
const zeroWindows = [];
|
|
68
|
+
let zeroFrom = since;
|
|
69
|
+
for (const edge of edges) {
|
|
70
|
+
if (count === 0 && edge.delta > 0 && zeroFrom !== undefined && edge.at > zeroFrom) {
|
|
71
|
+
zeroWindows.push({
|
|
72
|
+
from: new Date(zeroFrom).toISOString(),
|
|
73
|
+
to: new Date(edge.at).toISOString(),
|
|
74
|
+
seconds: Math.round((edge.at - zeroFrom) / 1000),
|
|
75
|
+
});
|
|
76
|
+
zeroFrom = undefined;
|
|
77
|
+
}
|
|
78
|
+
count += edge.delta;
|
|
79
|
+
if (count > peak.count)
|
|
80
|
+
peak = { count, at: new Date(edge.at).toISOString() };
|
|
81
|
+
if (edge.pool !== undefined) {
|
|
82
|
+
const next = (perPool.get(edge.pool) ?? 0) + edge.delta;
|
|
83
|
+
perPool.set(edge.pool, next);
|
|
84
|
+
const best = poolPeak.get(edge.pool);
|
|
85
|
+
if (best === undefined || next > best.count)
|
|
86
|
+
poolPeak.set(edge.pool, { count: next, at: new Date(edge.at).toISOString() });
|
|
87
|
+
}
|
|
88
|
+
if (count === 0)
|
|
89
|
+
zeroFrom = edge.at;
|
|
90
|
+
}
|
|
91
|
+
if (zeroFrom !== undefined && until > zeroFrom) {
|
|
92
|
+
zeroWindows.push({
|
|
93
|
+
from: new Date(zeroFrom).toISOString(),
|
|
94
|
+
to: new Date(until).toISOString(),
|
|
95
|
+
seconds: Math.round((until - zeroFrom) / 1000),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const secondsAtZero = zeroWindows.reduce((sum, w) => sum + w.seconds, 0);
|
|
99
|
+
const longestZero = [...zeroWindows].sort((a, b) => b.seconds - a.seconds)[0];
|
|
100
|
+
return {
|
|
101
|
+
since: window.since,
|
|
102
|
+
until: window.until,
|
|
103
|
+
counted: counted.length,
|
|
104
|
+
excluded,
|
|
105
|
+
peak,
|
|
106
|
+
peakByPool: [...poolPeak.entries()]
|
|
107
|
+
.map(([pool, p]) => ({ pool, count: p.count, at: p.at }))
|
|
108
|
+
.sort((a, b) => b.count - a.count || a.pool.localeCompare(b.pool)),
|
|
109
|
+
zeroWindows,
|
|
110
|
+
secondsAtZero,
|
|
111
|
+
longestZero,
|
|
112
|
+
emptyThroughout: peak.count === 0,
|
|
113
|
+
resolutionSeconds: POLL_WINDOW_SECONDS,
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
/** `24h`, `90m`, `2d`, `3600` (seconds) — what an operator types for a window. */
|
|
117
|
+
export const parseDuration = (input) => {
|
|
118
|
+
const m = /^(\d+(?:\.\d+)?)([smhd]?)$/.exec(input.trim());
|
|
119
|
+
if (!m)
|
|
120
|
+
return undefined;
|
|
121
|
+
const value = Number(m[1]);
|
|
122
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
123
|
+
return undefined;
|
|
124
|
+
const unit = m[2] === '' ? 's' : m[2];
|
|
125
|
+
const scale = { s: 1, m: 60, h: 3600, d: 86_400 }[unit];
|
|
126
|
+
return Math.round(value * scale);
|
|
127
|
+
};
|
|
128
|
+
export const humanSeconds = (seconds) => {
|
|
129
|
+
if (seconds < 90)
|
|
130
|
+
return `${seconds}s`;
|
|
131
|
+
if (seconds < 5400)
|
|
132
|
+
return `${Math.round(seconds / 60)}m`;
|
|
133
|
+
if (seconds < 172_800)
|
|
134
|
+
return `${(seconds / 3600).toFixed(1)}h`;
|
|
135
|
+
return `${(seconds / 86_400).toFixed(1)}d`;
|
|
136
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// domain layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
// The REST shapes for "what got slower" and "what is flaky", mirroring
|
|
3
|
+
// control-plane/contracts/openapi.yaml, plus the pure decisions the CLI makes
|
|
4
|
+
// about presenting them.
|
|
5
|
+
/**
|
|
6
|
+
* A duration a person can compare at a glance.
|
|
7
|
+
*
|
|
8
|
+
* Sub-second work is rounded to milliseconds and everything above a minute
|
|
9
|
+
* loses its milliseconds entirely: nobody reading "which step got slower"
|
|
10
|
+
* needs three decimal places on four minutes, and the extra digits make two
|
|
11
|
+
* numbers harder to compare rather than easier.
|
|
12
|
+
*/
|
|
13
|
+
export const humanDuration = (ms) => {
|
|
14
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
15
|
+
return '—';
|
|
16
|
+
if (ms < 1000)
|
|
17
|
+
return `${Math.round(ms)}ms`;
|
|
18
|
+
const seconds = ms / 1000;
|
|
19
|
+
if (seconds < 60)
|
|
20
|
+
return `${seconds.toFixed(1)}s`;
|
|
21
|
+
const whole = Math.round(seconds);
|
|
22
|
+
const minutes = Math.floor(whole / 60);
|
|
23
|
+
if (minutes < 60)
|
|
24
|
+
return `${minutes}m ${whole % 60}s`;
|
|
25
|
+
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
26
|
+
};
|
|
27
|
+
/** `+2.4× (was 10.0s)`, or nothing when there is no baseline to compare to. */
|
|
28
|
+
export const trendNote = (t) => {
|
|
29
|
+
if (t.baselineMs === undefined || t.ratio === undefined)
|
|
30
|
+
return undefined;
|
|
31
|
+
return `${t.ratio.toFixed(1)}× (was ${humanDuration(t.baselineMs)})`;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* A run's steps, slowest first, for a listing that has room for only a few.
|
|
35
|
+
*
|
|
36
|
+
* Sorted by duration rather than by document order because the question this
|
|
37
|
+
* answers is "where did the time go" — and a step that took four minutes is
|
|
38
|
+
* the answer whether it ran first or last.
|
|
39
|
+
*/
|
|
40
|
+
export const slowestFirst = (steps, limit) => [...steps].sort((a, b) => b.durationMs - a.durationMs).slice(0, limit);
|
|
41
|
+
/** A one-word marker per outcome, so a scan down the column reads. */
|
|
42
|
+
export const testMarker = (outcome) => outcome === 'passed' ? '✓' : outcome === 'skipped' ? '−' : '✗';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// domain layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
// The REST shapes for a log search and a structured log view, mirroring
|
|
3
|
+
// control-plane/contracts/openapi.yaml, plus the pure decisions the CLI makes
|
|
4
|
+
// about presenting them.
|
|
5
|
+
/**
|
|
6
|
+
* `ci.yml #12 · build` — how a result names the job it found.
|
|
7
|
+
*
|
|
8
|
+
* The workflow's file name rather than its display name: the reader's next act
|
|
9
|
+
* is to open that file, and two workflows may share a `name:`.
|
|
10
|
+
*/
|
|
11
|
+
export const resultTitle = (result) => {
|
|
12
|
+
const file = result.workflowPath.split('/').pop() ?? result.workflowPath;
|
|
13
|
+
const attempt = result.attempt > 1 ? ` (attempt ${result.attempt})` : '';
|
|
14
|
+
return `${file} #${result.runNumber}${attempt} · ${result.jobName}`;
|
|
15
|
+
};
|
|
16
|
+
/** `main · a1b2c3d` — the branch as people write it, and the commit. */
|
|
17
|
+
export const resultRef = (result) => `${result.ref.replace(/^refs\/heads\//, '')} · ${result.sha.slice(0, 7)}`;
|
|
18
|
+
/** Only what the tool itself stated. A missing count is missing, never zero. */
|
|
19
|
+
export const totalsPhrase = (view) => {
|
|
20
|
+
const parts = [];
|
|
21
|
+
if (view.totals.failed !== undefined)
|
|
22
|
+
parts.push(`${view.totals.failed} failed`);
|
|
23
|
+
if (view.totals.passed !== undefined)
|
|
24
|
+
parts.push(`${view.totals.passed} passed`);
|
|
25
|
+
if (view.totals.skipped !== undefined && view.totals.skipped > 0)
|
|
26
|
+
parts.push(`${view.totals.skipped} skipped`);
|
|
27
|
+
if (view.totals.total !== undefined)
|
|
28
|
+
parts.push(`${view.totals.total} total`);
|
|
29
|
+
return parts.length === 0 ? 'no counts reported' : parts.join(', ');
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* What the reader should trust, in a sentence.
|
|
33
|
+
*
|
|
34
|
+
* `report` is the only case that needs saying: the numbers on screen came from
|
|
35
|
+
* reading the runner's prose, and a machine-readable statement of the same run
|
|
36
|
+
* exists and outranks them.
|
|
37
|
+
*/
|
|
38
|
+
export const authorityNote = (view) => view.authority === 'report'
|
|
39
|
+
? 'this job also left a test report, which is the runner’s own statement — `apex tests` reads that; the counts here are read from the console output and the line numbers are what they add'
|
|
40
|
+
: undefined;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// domain layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
// The migration report as the engine produces it (engine ADR-0013,
|
|
3
|
+
// engine/docs/compat-format.md), plus the re-entry checklist built on top.
|
|
4
|
+
//
|
|
5
|
+
// Nothing here decides what is supported. Every verdict is a row of the
|
|
6
|
+
// engine's conformance corpus, and a second opinion in this file would be a
|
|
7
|
+
// second answer to a question that must have one — it would agree on the day
|
|
8
|
+
// it was written and drift, silently, the first time a fixture changed.
|
|
9
|
+
/**
|
|
10
|
+
* Secrets nobody configures, because the platform mints them per job.
|
|
11
|
+
*
|
|
12
|
+
* `secrets.GITHUB_TOKEN` is in every second workflow on GitHub and is in no
|
|
13
|
+
* organization's secret list, so a checklist that did not know this would open
|
|
14
|
+
* every real scan by telling somebody to go and find a secret that does not
|
|
15
|
+
* exist — which is how a list stops being read. Found by running the scan
|
|
16
|
+
* against a real organization; every path had been tested against a fake.
|
|
17
|
+
*/
|
|
18
|
+
export const AUTOMATIC_SECRETS = ['GITHUB_TOKEN'];
|
|
19
|
+
const dedupe = (values) => [...new Set(values)].sort();
|
|
20
|
+
/**
|
|
21
|
+
* Build the re-entry checklist: what must be set in Apex before a first run,
|
|
22
|
+
* what the workflows read that GitHub cannot account for, and what can be left
|
|
23
|
+
* behind.
|
|
24
|
+
*
|
|
25
|
+
* `configured` may be empty because there was nothing, or because nobody could
|
|
26
|
+
* read it. Those are different states and the second one is recorded, because
|
|
27
|
+
* an empty checklist that looks complete is exactly the failure this list
|
|
28
|
+
* exists to prevent.
|
|
29
|
+
*/
|
|
30
|
+
export const buildChecklist = (referenced, configured) => {
|
|
31
|
+
const entries = [];
|
|
32
|
+
const byKind = (kind, names) => {
|
|
33
|
+
const configuredOfKind = (configured ?? []).filter((c) => c.kind === kind);
|
|
34
|
+
const referencedNames = dedupe(names);
|
|
35
|
+
for (const name of referencedNames) {
|
|
36
|
+
const matches = configuredOfKind.filter((c) => c.name === name);
|
|
37
|
+
const action = kind === 'secret' && AUTOMATIC_SECRETS.includes(name)
|
|
38
|
+
? 'automatic'
|
|
39
|
+
: configured === undefined || matches.length > 0
|
|
40
|
+
? 're-enter'
|
|
41
|
+
: 'unaccounted';
|
|
42
|
+
entries.push({
|
|
43
|
+
name,
|
|
44
|
+
kind,
|
|
45
|
+
action,
|
|
46
|
+
...(matches[0]?.scope === undefined ? {} : { scope: matches[0].scope }),
|
|
47
|
+
...(matches.some((m) => m.repo !== undefined)
|
|
48
|
+
? { repos: dedupe(matches.flatMap((m) => (m.repo === undefined ? [] : [m.repo]))) }
|
|
49
|
+
: {}),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
for (const name of dedupe(configuredOfKind.map((c) => c.name))) {
|
|
53
|
+
if (referencedNames.includes(name))
|
|
54
|
+
continue;
|
|
55
|
+
const matches = configuredOfKind.filter((c) => c.name === name);
|
|
56
|
+
entries.push({
|
|
57
|
+
name,
|
|
58
|
+
kind,
|
|
59
|
+
action: 'not-needed',
|
|
60
|
+
...(matches[0]?.scope === undefined ? {} : { scope: matches[0].scope }),
|
|
61
|
+
...(matches.some((m) => m.repo !== undefined)
|
|
62
|
+
? { repos: dedupe(matches.flatMap((m) => (m.repo === undefined ? [] : [m.repo]))) }
|
|
63
|
+
: {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
byKind('secret', referenced.secrets ?? []);
|
|
68
|
+
byKind('variable', referenced.vars ?? []);
|
|
69
|
+
return { entries, configuredNamesRead: configured !== undefined };
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Where this organization stands.
|
|
73
|
+
*
|
|
74
|
+
* `incomplete` wins over everything: a file we could not read is not a file
|
|
75
|
+
* that runs, and a report that called an organization ready while three of its
|
|
76
|
+
* workflows failed to parse would be worse than no report.
|
|
77
|
+
*/
|
|
78
|
+
export const migrationVerdict = (report) => {
|
|
79
|
+
if (report.summary.unreadable > 0)
|
|
80
|
+
return 'incomplete';
|
|
81
|
+
if (report.summary.blockers > 0)
|
|
82
|
+
return 'blocked';
|
|
83
|
+
if (report.summary.partial > 0 || report.summary.unknown > 0 || report.caveats.length > 0)
|
|
84
|
+
return 'ready-with-caveats';
|
|
85
|
+
return 'ready';
|
|
86
|
+
};
|
|
87
|
+
export const verdictHeadline = (verdict) => {
|
|
88
|
+
switch (verdict) {
|
|
89
|
+
case 'ready':
|
|
90
|
+
return 'Every feature these workflows use runs on Apex Actions, and a fixture proves each one.';
|
|
91
|
+
case 'ready-with-caveats':
|
|
92
|
+
return 'These workflows run, with limits worth reading before you move them.';
|
|
93
|
+
case 'blocked':
|
|
94
|
+
return 'Something these workflows use does not run on Apex Actions.';
|
|
95
|
+
case 'incomplete':
|
|
96
|
+
return 'Some workflows could not be read, so this report does not cover them.';
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
/** `n thing` / `n things`, because a report that says "1 files" is not read carefully. */
|
|
100
|
+
export const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
101
|
+
/**
|
|
102
|
+
* How a verdict's proof reads in the report.
|
|
103
|
+
*
|
|
104
|
+
* A verdict with no fixture never says "proved by" anything. "Supported" and
|
|
105
|
+
* "proved supported" are different sentences, and the whole point of the
|
|
106
|
+
* corpus is that only the second one is the product's claim.
|
|
107
|
+
*/
|
|
108
|
+
export const proofLine = (verdict) => {
|
|
109
|
+
if (verdict.status === 'unknown')
|
|
110
|
+
return 'no row of the compatibility matrix claims this';
|
|
111
|
+
if (verdict.proven && verdict.fixtures !== undefined && verdict.fixtures.length > 0) {
|
|
112
|
+
return `proved by ${verdict.fixtures.join(', ')}`;
|
|
113
|
+
}
|
|
114
|
+
return verdict.status === 'supported'
|
|
115
|
+
? 'claimed, with no conformance fixture behind the claim'
|
|
116
|
+
: 'recorded in the matrix; no fixture demonstrates the limit';
|
|
117
|
+
};
|
|
118
|
+
/** Where a use is, as a reader would go and look at it. */
|
|
119
|
+
export const useLocation = (use) => {
|
|
120
|
+
const at = use.path === undefined ? use.where : `${use.path}${use.line === undefined ? '' : `:${use.line}`}`;
|
|
121
|
+
return use.detail === undefined || use.detail === '' ? at : `${at} — ${use.detail}`;
|
|
122
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// domain layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
// The REST shapes for notification routing, mirroring
|
|
3
|
+
// control-plane/contracts/openapi.yaml, plus the pure presentation decisions
|
|
4
|
+
// the CLI makes about them.
|
|
5
|
+
//
|
|
6
|
+
// There is no destination URL in any of these types, and that is not an
|
|
7
|
+
// oversight: the control plane never returns one (control-plane ADR-0029), so
|
|
8
|
+
// a field for it here would be a field that is always empty and would invite
|
|
9
|
+
// somebody to try to fill it.
|
|
10
|
+
export const TRIGGERS = ['broke', 'recovered', 'failed', 'succeeded'];
|
|
11
|
+
export const DESTINATION_KINDS = ['slack', 'teams', 'webhook'];
|
|
12
|
+
/** `slack · hooks.slack.com · #a41f9c0d1e2f` — how a destination is recognised without being readable. */
|
|
13
|
+
export const destinationLine = (rule) => `${rule.kind} ${rule.urlOrigin.replace(/^https:\/\//, '')} #${rule.urlFingerprint}`;
|
|
14
|
+
/** Empty filters mean everything, and saying so beats printing a blank. */
|
|
15
|
+
export const filterLine = (patterns) => (patterns.length === 0 ? '(all)' : patterns.join(', '));
|
|
16
|
+
/** What a comma-or-repeated option means. Empty entries are dropped, not preserved as blanks. */
|
|
17
|
+
export const listOption = (raw) => (raw ?? []).flatMap((v) => v.split(',')).map((v) => v.trim()).filter((v) => v !== '');
|
|
18
|
+
export const invalidTriggers = (values) => values.filter((v) => !TRIGGERS.includes(v));
|
|
19
|
+
/**
|
|
20
|
+
* One line summarising the last attempt.
|
|
21
|
+
*
|
|
22
|
+
* A rule that has never been used says so rather than reading as healthy: an
|
|
23
|
+
* empty status is the state a rule is in before anybody has proved it, and
|
|
24
|
+
* that is exactly what somebody debugging a silent channel needs to see.
|
|
25
|
+
*/
|
|
26
|
+
export const lastAttemptLine = (rule) => {
|
|
27
|
+
if (rule.lastStatus === undefined)
|
|
28
|
+
return 'never used — try `apex notify test`';
|
|
29
|
+
const when = rule.lastAttemptAt === undefined ? '' : ` ${rule.lastAttemptAt}`;
|
|
30
|
+
const detail = rule.lastDetail === undefined || rule.lastDetail === '' ? '' : ` · ${rule.lastDetail}`;
|
|
31
|
+
return `${rule.lastStatus}${when}${detail}`;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* How one attempt went, in the words somebody acts on.
|
|
35
|
+
*
|
|
36
|
+
* Three outcomes, not two. A refusal is ours and a rejection is theirs, and
|
|
37
|
+
* that distinction was already drawn — but a send that never got an answer at
|
|
38
|
+
* all is neither, and calling it a rejection points the reader at the
|
|
39
|
+
* destination when the fault is on this side of the socket. It read as Slack
|
|
40
|
+
* turning us away while the real cause was a `TypeError` in our own resolver
|
|
41
|
+
* (control-plane ADR-0036), which is a day of somebody's time in the wrong
|
|
42
|
+
* logs.
|
|
43
|
+
*/
|
|
44
|
+
export const sendOutcomeLine = (outcome) => {
|
|
45
|
+
if (outcome.refused === true)
|
|
46
|
+
return 'refused before connecting';
|
|
47
|
+
if (outcome.status === undefined)
|
|
48
|
+
return 'never reached the destination';
|
|
49
|
+
return `rejected by the destination (HTTP ${String(outcome.status)})`;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* What a rule will actually do, in one sentence.
|
|
53
|
+
*
|
|
54
|
+
* Written out because the default subscription is the thing people misread:
|
|
55
|
+
* "broke, recovered" looks like it might mean *every* failure, and it does not
|
|
56
|
+
* (control-plane ADR-0028).
|
|
57
|
+
*/
|
|
58
|
+
export const subscriptionLine = (rule) => {
|
|
59
|
+
const transitions = rule.on.filter((t) => t === 'broke' || t === 'recovered');
|
|
60
|
+
const everyTime = rule.on.filter((t) => t === 'failed' || t === 'succeeded');
|
|
61
|
+
if (everyTime.length > 0)
|
|
62
|
+
return `${rule.on.join(', ')} — ${everyTime.join(' and ')} fire on every run of that kind`;
|
|
63
|
+
return `${transitions.join(', ')} — only when the outcome changes`;
|
|
64
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// domain layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
//
|
|
3
|
+
// How the coexistence outcome diff reads to a person. The comparison itself is
|
|
4
|
+
// the control plane's (ADR-0047); this is the sentence it deserves.
|
|
5
|
+
/** One line per workflow, saying what happened and — when it matters — why not. */
|
|
6
|
+
export const comparisonLine = (c) => {
|
|
7
|
+
const both = `${c.ours ?? '—'} / ${c.theirs ?? '—'}`;
|
|
8
|
+
switch (c.verdict) {
|
|
9
|
+
case 'agree':
|
|
10
|
+
return `both ${c.ours ?? '—'}`;
|
|
11
|
+
case 'differ':
|
|
12
|
+
return `apex ${c.ours ?? '—'}, github ${c.theirs ?? '—'}`;
|
|
13
|
+
case 'not-run-here':
|
|
14
|
+
return 'not run here — opt it in to compare it';
|
|
15
|
+
case 'not-run-there':
|
|
16
|
+
return 'GitHub Actions has no run for this commit';
|
|
17
|
+
case 'in-flight':
|
|
18
|
+
return `still running (${both})`;
|
|
19
|
+
case 'incomparable-secrets':
|
|
20
|
+
return `not comparable: this workflow reads ${c.missingSecrets.join(', ')}, which we do not hold`;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The headline, and the only honest denominator.
|
|
25
|
+
*
|
|
26
|
+
* `comparable` is `agree + differ`. A repository whose workflows all need
|
|
27
|
+
* secrets we lack has **nothing** to report, and saying "100%" of an empty set
|
|
28
|
+
* is the failure this whole feature is built to avoid — so that case gets a
|
|
29
|
+
* sentence rather than a number.
|
|
30
|
+
*/
|
|
31
|
+
export const diffHeadline = (s) => {
|
|
32
|
+
if (s.comparable === 0) {
|
|
33
|
+
return s.incomparable === 0
|
|
34
|
+
? 'nothing ran on either platform for this commit'
|
|
35
|
+
: `nothing could be compared for this commit (${s.incomparable} not comparable)`;
|
|
36
|
+
}
|
|
37
|
+
const pct = Math.round((s.agree / s.comparable) * 100);
|
|
38
|
+
const tail = s.incomparable === 0 ? '' : `, ${s.incomparable} not comparable`;
|
|
39
|
+
return `${s.agree} of ${s.comparable} comparable workflows agree (${pct}%)${tail}`;
|
|
40
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// domain layer — see ../../../../docs/conventions.md §1
|
|
2
|
+
// The REST shapes for "why did this run" and "how do I run it here", mirroring
|
|
3
|
+
// control-plane/contracts/openapi.yaml, plus the pure decisions the CLI makes
|
|
4
|
+
// about presenting them.
|
|
5
|
+
/**
|
|
6
|
+
* The retry note for one job, or `undefined` when it ran first time.
|
|
7
|
+
*
|
|
8
|
+
* Shown even on a job that succeeded, because that is the interesting case: a
|
|
9
|
+
* Spot reclaim that cost a delay rather than a run is the thing GA gate G3 asks
|
|
10
|
+
* somebody to witness, and by then the job's own `reason` is about its
|
|
11
|
+
* conclusion.
|
|
12
|
+
*/
|
|
13
|
+
export const retryNote = (job) => {
|
|
14
|
+
const attempt = job.attempt ?? 1;
|
|
15
|
+
if (attempt < 2 && job.requeueReason === undefined)
|
|
16
|
+
return undefined;
|
|
17
|
+
const why = job.requeueReason === 'spot-interruption'
|
|
18
|
+
? 'instance reclaimed by Spot'
|
|
19
|
+
: job.requeueReason === 'lease-expired'
|
|
20
|
+
? 'runner stopped reporting'
|
|
21
|
+
: job.requeueReason === 'shutdown'
|
|
22
|
+
? 'runner shutting down'
|
|
23
|
+
: job.requeueReason;
|
|
24
|
+
return why === undefined ? `attempt ${attempt}` : `attempt ${attempt} — ${why}`;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* A verdict worth printing under "and here is what else this delivery
|
|
28
|
+
* decided". `planned` is the run the reader is already looking at, and
|
|
29
|
+
* `already-planned` is bookkeeping about a reclaimed claim — neither answers
|
|
30
|
+
* the question that brought them here.
|
|
31
|
+
*/
|
|
32
|
+
export const worthShowing = (t) => t.outcome !== 'planned' && t.outcome !== 'already-planned';
|
|
33
|
+
/**
|
|
34
|
+
* One line describing a verdict, ending in the filter list when there is one.
|
|
35
|
+
*
|
|
36
|
+
* The filter is printed as the workflow wrote it, because the next thing the
|
|
37
|
+
* reader does is open that file and change that line — quoting our paraphrase
|
|
38
|
+
* of it would send them looking for words that are not there.
|
|
39
|
+
*/
|
|
40
|
+
export const verdictLine = (t) => {
|
|
41
|
+
const head = t.reason ?? t.summary ?? t.outcome;
|
|
42
|
+
const filter = t.filter ?? [];
|
|
43
|
+
return filter.length === 0 ? head : `${head} — the trigger allows ${filter.join(', ')}`;
|
|
44
|
+
};
|
|
45
|
+
/** A one-word status marker for a job, chosen so a scan down the column reads. */
|
|
46
|
+
export const jobMarker = (job) => {
|
|
47
|
+
if (job.status !== 'completed')
|
|
48
|
+
return job.status === 'in_progress' ? '›' : '·';
|
|
49
|
+
switch (job.conclusion) {
|
|
50
|
+
case 'success':
|
|
51
|
+
return '✓';
|
|
52
|
+
case 'skipped':
|
|
53
|
+
return '−';
|
|
54
|
+
case 'cancelled':
|
|
55
|
+
return '⨯';
|
|
56
|
+
default:
|
|
57
|
+
return '✗';
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
/** Shell-quote one argument of a generated command, so it can be pasted as-is. */
|
|
61
|
+
export const shellQuote = (arg) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`);
|
|
62
|
+
/** The generated invocation as one pasteable line. */
|
|
63
|
+
export const commandLine = (command) => command.map(shellQuote).join(' ');
|