opstruth 0.1.2 → 0.2.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/README.md +26 -2
- package/examples/supabase-live-redacted-evidence.json +78 -0
- package/package.json +1 -1
- package/src/cli.js +77 -40
- package/src/commands/github-ci.js +338 -0
- package/src/commands/local.js +7 -1
- package/src/commands/probes.js +33 -15
- package/src/commands/quality.js +212 -20
- package/src/commands/routes.js +60 -9
- package/src/commands/secrets.js +37 -12
- package/src/commands/supabase-live.js +564 -0
- package/src/lib/config.js +41 -3
- package/src/lib/exec.js +21 -3
- package/src/lib/git.js +5 -3
- package/src/lib/markdown.js +21 -0
- package/src/lib/probes.js +44 -2
- package/src/lib/redact.js +1 -0
- package/src/lib/scan.js +241 -14
- package/src/orchestrator.js +39 -17
package/README.md
CHANGED
|
@@ -6,13 +6,16 @@ Read-only operational truth checks for AI-assisted engineering workflows.
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Current published package:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm install -g opstruth
|
|
13
13
|
opstruth
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
The latest published npm package is `opstruth@0.1.3`. This source package is prepared for
|
|
17
|
+
`opstruth@0.2.0`, pending an authenticated npm publish.
|
|
18
|
+
|
|
16
19
|
One-off usage:
|
|
17
20
|
|
|
18
21
|
```bash
|
|
@@ -24,17 +27,28 @@ npx opstruth
|
|
|
24
27
|
```bash
|
|
25
28
|
opstruth
|
|
26
29
|
opstruth welcome
|
|
30
|
+
opstruth init --yes
|
|
31
|
+
opstruth repo
|
|
32
|
+
opstruth quality
|
|
33
|
+
opstruth github-ci --workflow CI
|
|
27
34
|
opstruth probes
|
|
28
35
|
opstruth secrets
|
|
29
36
|
opstruth routes --base-url https://example.com
|
|
30
37
|
opstruth local --port 3000 --health /health
|
|
38
|
+
opstruth supabase
|
|
39
|
+
opstruth supabase-live --evidence-file <redacted.json>
|
|
40
|
+
opstruth supabase-live --telemetry-file /tmp/opstruth-supabase-telemetry.json
|
|
41
|
+
opstruth cloudflare
|
|
42
|
+
opstruth evidence
|
|
43
|
+
opstruth --json
|
|
44
|
+
opstruth --no-color
|
|
31
45
|
```
|
|
32
46
|
|
|
33
47
|
## Terminal Output
|
|
34
48
|
|
|
35
49
|
Human output uses a restrained colour theme for status, warnings, proof gaps, evidence, and next safe steps when the terminal supports ANSI colour.
|
|
36
50
|
|
|
37
|
-
Use `--no-color` or `NO_COLOR=1` to disable colour. Use `--color` to force colour for demos. JSON output remains machine-readable and does not include ANSI codes.
|
|
51
|
+
Use `--no-color` or `NO_COLOR=1` to disable colour. Use `--color` to force colour for demos. JSON output remains machine-readable and does not include ANSI codes. Evidence markdown output remains ANSI-free.
|
|
38
52
|
|
|
39
53
|
## Safety Model
|
|
40
54
|
|
|
@@ -42,6 +56,16 @@ opstruth is read-only by default. CLI checks do not deploy, mutate databases, tr
|
|
|
42
56
|
|
|
43
57
|
Skipped checks and not-verified areas are reported as proof gaps instead of being treated as safe.
|
|
44
58
|
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
`opstruth.config.json` can provide route paths, local ports/health paths, and secret allowlists. Generate a starter config:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
opstruth init --yes
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
CLI flags remain the clearest way to provide runtime inputs.
|
|
68
|
+
|
|
45
69
|
## Repository
|
|
46
70
|
|
|
47
71
|
Source, docs, and release evidence live at:
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "opstruth.supabase-live.v1",
|
|
3
|
+
"collectedAt": "2026-06-28T10:00:00.000Z",
|
|
4
|
+
"repositoryCommit": "example-short-sha",
|
|
5
|
+
"functionName": "import-reddit-tips",
|
|
6
|
+
"schedulerJob": "import-reddit-tips-daily",
|
|
7
|
+
"evidenceSource": "redacted local operator evidence",
|
|
8
|
+
"manualOrAutonomous": "autonomous",
|
|
9
|
+
"signals": {
|
|
10
|
+
"function_deployed": {
|
|
11
|
+
"state": "verified",
|
|
12
|
+
"summary": "Function deployment metadata was observed."
|
|
13
|
+
},
|
|
14
|
+
"secret_name_configured": {
|
|
15
|
+
"state": "verified",
|
|
16
|
+
"summary": "Secret name presence was observed; secret value was not inspected."
|
|
17
|
+
},
|
|
18
|
+
"missing_credential_denial": {
|
|
19
|
+
"state": "verified",
|
|
20
|
+
"summary": "Missing credential denial was observed."
|
|
21
|
+
},
|
|
22
|
+
"incorrect_credential_denial": {
|
|
23
|
+
"state": "verified",
|
|
24
|
+
"summary": "Incorrect credential denial was observed."
|
|
25
|
+
},
|
|
26
|
+
"authorised_noop": {
|
|
27
|
+
"state": "verified",
|
|
28
|
+
"summary": "Authorised no-op response was observed with zero inserts."
|
|
29
|
+
},
|
|
30
|
+
"scheduler_configured": {
|
|
31
|
+
"state": "verified",
|
|
32
|
+
"summary": "One intended scheduler job was observed."
|
|
33
|
+
},
|
|
34
|
+
"scheduler_autonomous_execution": {
|
|
35
|
+
"state": "verified",
|
|
36
|
+
"summary": "Autonomous scheduler run history was observed."
|
|
37
|
+
},
|
|
38
|
+
"telemetry_count_only": {
|
|
39
|
+
"state": "not_verified",
|
|
40
|
+
"summary": "Filtered production function logs were not available."
|
|
41
|
+
},
|
|
42
|
+
"non_admin_authorization": {
|
|
43
|
+
"state": "authentication_unavailable",
|
|
44
|
+
"summary": "No safe existing non-admin identity was available."
|
|
45
|
+
},
|
|
46
|
+
"admin_authorization": {
|
|
47
|
+
"state": "authentication_unavailable",
|
|
48
|
+
"summary": "No safe existing admin identity was available."
|
|
49
|
+
},
|
|
50
|
+
"rate_limit": {
|
|
51
|
+
"state": "unsafe_to_test",
|
|
52
|
+
"summary": "Testing would update production rate-limit state."
|
|
53
|
+
},
|
|
54
|
+
"database_effects": {
|
|
55
|
+
"state": "verified",
|
|
56
|
+
"summary": "Scoped count-only database effects were observed."
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"databaseScope": {
|
|
60
|
+
"tables": [
|
|
61
|
+
"pet_tips"
|
|
62
|
+
],
|
|
63
|
+
"rowCountsOnly": true,
|
|
64
|
+
"rowsDumped": false
|
|
65
|
+
},
|
|
66
|
+
"redactionsApplied": [
|
|
67
|
+
"project reference omitted",
|
|
68
|
+
"credential headers omitted",
|
|
69
|
+
"raw logs omitted",
|
|
70
|
+
"scheduler payload omitted"
|
|
71
|
+
],
|
|
72
|
+
"notVerified": [
|
|
73
|
+
"function log telemetry",
|
|
74
|
+
"non-admin credential branch",
|
|
75
|
+
"admin credential branch",
|
|
76
|
+
"rate-limit recovery"
|
|
77
|
+
]
|
|
78
|
+
}
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -11,13 +11,15 @@ import { runCloudflare } from './commands/cloudflare.js';
|
|
|
11
11
|
import { runLocal } from './commands/local.js';
|
|
12
12
|
import { runEvidence } from './commands/evidence.js';
|
|
13
13
|
import { runProbes } from './commands/probes.js';
|
|
14
|
+
import { runGitHubCi } from './commands/github-ci.js';
|
|
15
|
+
import { runSupabaseLive } from './commands/supabase-live.js';
|
|
14
16
|
import { resultToMarkdown } from './lib/markdown.js';
|
|
15
17
|
import { formatTerminalOutput } from './lib/terminal.js';
|
|
16
18
|
import { writeFileSafe } from './lib/fs.js';
|
|
17
19
|
import { redactObject } from './lib/redact.js';
|
|
18
20
|
import { exitCodeFor } from './lib/result.js';
|
|
19
21
|
|
|
20
|
-
const COMMANDS = new Set(['repo', 'quality', 'routes', 'secrets', 'supabase', 'cloudflare', 'local', 'evidence', 'probes', 'welcome', 'init']);
|
|
22
|
+
const COMMANDS = new Set(['repo', 'quality', 'routes', 'secrets', 'supabase', 'supabase-live', 'cloudflare', 'local', 'github-ci', 'evidence', 'probes', 'welcome', 'init']);
|
|
21
23
|
function take(args, index) { return args[index + 1]; }
|
|
22
24
|
export function parseArgs(argv) {
|
|
23
25
|
const options = { skip: [], only: [], port: [], protectedTable: [], include: [] };
|
|
@@ -49,6 +51,10 @@ export function parseArgs(argv) {
|
|
|
49
51
|
else if (arg === '--process') options.process = take(argv, i++);
|
|
50
52
|
else if (arg === '--service') options.service = take(argv, i++);
|
|
51
53
|
else if (arg === '--script') { options.scripts ||= []; options.scripts.push(take(argv, i++)); }
|
|
54
|
+
else if (arg === '--github-ci') options.githubCi = true;
|
|
55
|
+
else if (arg === '--workflow') options.workflow = take(argv, i++);
|
|
56
|
+
else if (arg === '--evidence-file') options.evidenceFile = take(argv, i++);
|
|
57
|
+
else if (arg === '--telemetry-file') options.telemetryFile = take(argv, i++);
|
|
52
58
|
}
|
|
53
59
|
if (!options.protectedTable.length) delete options.protectedTable;
|
|
54
60
|
return { command, options };
|
|
@@ -62,8 +68,10 @@ async function dispatch(command, options) {
|
|
|
62
68
|
if (command === 'routes') return runRoutes(options);
|
|
63
69
|
if (command === 'secrets') return runSecrets(options);
|
|
64
70
|
if (command === 'supabase') return runSupabase(options);
|
|
71
|
+
if (command === 'supabase-live') return runSupabaseLive(options);
|
|
65
72
|
if (command === 'cloudflare') return runCloudflare(options);
|
|
66
73
|
if (command === 'local') return runLocal(options);
|
|
74
|
+
if (command === 'github-ci') return runGitHubCi(options);
|
|
67
75
|
if (command === 'evidence') return runEvidence(options);
|
|
68
76
|
if (command === 'probes') return runProbes(options);
|
|
69
77
|
throw new Error('Unknown command: ' + command);
|
|
@@ -100,8 +108,10 @@ function helpText(command) {
|
|
|
100
108
|
' routes Probe configured HTTP routes with HEAD/GET',
|
|
101
109
|
' secrets Scan source for risky references with redaction',
|
|
102
110
|
' supabase Static Supabase safety checks',
|
|
111
|
+
' supabase-live Validate redacted Supabase production evidence',
|
|
103
112
|
' cloudflare Static Cloudflare/Wrangler checks',
|
|
104
113
|
' local Check explicit local ports/processes/services',
|
|
114
|
+
' github-ci Read GitHub Actions metadata for the exact local commit',
|
|
105
115
|
' evidence Write a markdown evidence pack',
|
|
106
116
|
' probes Inspect the stack-aware probe catalogue',
|
|
107
117
|
'',
|
|
@@ -115,34 +125,45 @@ function helpText(command) {
|
|
|
115
125
|
' --no-color Disable colour for human terminal output',
|
|
116
126
|
' -h, --help Print help and exit 0'
|
|
117
127
|
];
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
'Usage: opstruth
|
|
121
|
-
'',
|
|
122
|
-
'
|
|
123
|
-
'',
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
'
|
|
130
|
-
'
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
128
|
+
const commandHelp = {
|
|
129
|
+
repo: ['Usage: opstruth repo [--json] [--strict]', 'Inspect cwd, git root, branch, latest commit, dirty files, and detected stack.'],
|
|
130
|
+
quality: ['Usage: opstruth quality [--script name] [--continue] [--json]', 'Run only existing safe package scripts; missing scripts and npm placeholder tests are skipped.'],
|
|
131
|
+
routes: ['Usage: opstruth routes --base-url <url> [--routes file] [--json]', 'Collect read-only URL, method, status, latency, redirect, and header evidence.'],
|
|
132
|
+
secrets: ['Usage: opstruth secrets [--json]', 'Scan source text for risky secret/auth references with redacted previews; .env contents are skipped.'],
|
|
133
|
+
supabase: ['Usage: opstruth supabase [--protected-table name] [--frontend-dir dir] [--migrations-dir dir]', 'Run a static Supabase migration/frontend exposure audit without credentials or database calls.'],
|
|
134
|
+
'supabase-live': ['Usage: opstruth supabase-live --evidence-file <redacted.json> [--telemetry-file /tmp/redacted-provider-output.json] [--json]', 'Validate explicit redacted Supabase production evidence without credentials, mutation, or network calls.'],
|
|
135
|
+
cloudflare: ['Usage: opstruth cloudflare [--url https://example.com] [--json]', 'Inspect Wrangler config, deploy scripts, and optional read-only route status; no deploy is run.'],
|
|
136
|
+
local: ['Usage: opstruth local --port 3000 [--health /health] [--process name] [--service name]', 'Check explicit local runtime inputs without starting, stopping, or killing services.'],
|
|
137
|
+
'github-ci': ['Usage: opstruth github-ci [--workflow CI] [--json]', 'Read GitHub Actions run metadata for the exact local commit. No logs, deploys, or production calls are made.'],
|
|
138
|
+
probes: ['Usage: opstruth probes [--json] [--only area|id] [--skip area|id]', 'Inspect probe catalogue metadata, eligible probes, skipped probes, required inputs, and proof gaps.'],
|
|
139
|
+
evidence: ['Usage: opstruth evidence [--title text] [--out evidence/opstruth.md] [--include file]', 'Write a markdown evidence pack with verified facts, proof gaps, boundaries, and next safe step.'],
|
|
140
|
+
init: ['Usage: opstruth init [--yes]', 'Create a safe starter opstruth.config.json after confirmation.']
|
|
141
|
+
};
|
|
142
|
+
if (commandHelp[command]) {
|
|
143
|
+
const [usage, description] = commandHelp[command];
|
|
144
|
+
return [
|
|
145
|
+
ASCII_HEADER,
|
|
146
|
+
usage,
|
|
147
|
+
'',
|
|
148
|
+
description,
|
|
149
|
+
'',
|
|
150
|
+
'Examples:',
|
|
151
|
+
' opstruth',
|
|
152
|
+
' opstruth --base-url https://example.com',
|
|
153
|
+
' opstruth routes --base-url https://example.com',
|
|
154
|
+
' opstruth local --port 3000 --health /health',
|
|
155
|
+
' opstruth --json',
|
|
156
|
+
' opstruth --no-color',
|
|
157
|
+
' opstruth --color',
|
|
158
|
+
'',
|
|
159
|
+
'Options:',
|
|
160
|
+
' --json Print JSON output',
|
|
161
|
+
' --strict Treat warnings/skips as failing confidence',
|
|
162
|
+
' --color Force colour for human terminal output',
|
|
163
|
+
' --no-color Disable colour for human terminal output',
|
|
164
|
+
' -h, --help Print help and exit 0'
|
|
165
|
+
].join('\n') + '\n';
|
|
166
|
+
}
|
|
146
167
|
return common.join('\n') + '\n';
|
|
147
168
|
}
|
|
148
169
|
|
|
@@ -165,8 +186,17 @@ function welcomeText() {
|
|
|
165
186
|
'- opstruth --strict',
|
|
166
187
|
'- opstruth routes --base-url https://example.com',
|
|
167
188
|
'- opstruth local --port 3000 --health /health',
|
|
189
|
+
'- opstruth --json',
|
|
190
|
+
'- opstruth --no-color',
|
|
191
|
+
'- opstruth --color',
|
|
168
192
|
'- opstruth evidence --title "Release proof"',
|
|
169
193
|
'',
|
|
194
|
+
'Improving confidence:',
|
|
195
|
+
'- provide --base-url or route config when route proof matters',
|
|
196
|
+
'- provide --port and --health when local runtime proof matters',
|
|
197
|
+
'- run inside a git repo for stronger change evidence',
|
|
198
|
+
'- attach evidence/opstruth-report.md to reviews or CI artifacts',
|
|
199
|
+
'',
|
|
170
200
|
'Safety philosophy:',
|
|
171
201
|
'opstruth prefers skipped or not verified over pretending something is safe. Dangerous actions require explicit approval and are not part of the default run.',
|
|
172
202
|
''
|
|
@@ -174,27 +204,34 @@ function welcomeText() {
|
|
|
174
204
|
}
|
|
175
205
|
|
|
176
206
|
const INIT_CONFIG = {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
'strict-transport-security',
|
|
183
|
-
'x-frame-options',
|
|
184
|
-
'referrer-policy'
|
|
185
|
-
]
|
|
186
|
-
},
|
|
207
|
+
projectName: 'example',
|
|
208
|
+
routes: [
|
|
209
|
+
{ path: '/', expectedStatus: 200 },
|
|
210
|
+
{ path: '/health', expectedStatus: 200 }
|
|
211
|
+
],
|
|
187
212
|
local: {
|
|
188
213
|
ports: [],
|
|
189
|
-
|
|
214
|
+
healthPaths: ['/health']
|
|
215
|
+
},
|
|
216
|
+
quality: {
|
|
217
|
+
skipScripts: [],
|
|
218
|
+
requiredScripts: []
|
|
219
|
+
},
|
|
220
|
+
secrets: {
|
|
221
|
+
allowlistPaths: [],
|
|
222
|
+
allowlistPatterns: []
|
|
190
223
|
},
|
|
191
224
|
supabase: {
|
|
225
|
+
enabled: true,
|
|
192
226
|
protectedTables: [
|
|
193
227
|
'agent_jobs',
|
|
194
228
|
'platform_credentials',
|
|
195
229
|
'worker_logs'
|
|
196
230
|
]
|
|
197
231
|
},
|
|
232
|
+
cloudflare: {
|
|
233
|
+
enabled: true
|
|
234
|
+
},
|
|
198
235
|
ignore: [
|
|
199
236
|
'.cache',
|
|
200
237
|
'.agents',
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { runCommand } from '../lib/exec.js';
|
|
2
|
+
import { gitText } from '../lib/git.js';
|
|
3
|
+
import { createFinding, createResult } from '../lib/result.js';
|
|
4
|
+
import { loadOpstruthConfig } from '../lib/config.js';
|
|
5
|
+
import { resolveProjectBoundary } from '../lib/boundary.js';
|
|
6
|
+
|
|
7
|
+
const TERMINAL_STATES = new Set(['completed', 'success', 'failure', 'cancelled', 'skipped', 'timed_out']);
|
|
8
|
+
|
|
9
|
+
export const GITHUB_CI_STATES = [
|
|
10
|
+
'verified_success',
|
|
11
|
+
'verified_failure',
|
|
12
|
+
'in_progress',
|
|
13
|
+
'no_run_for_commit',
|
|
14
|
+
'authentication_unavailable',
|
|
15
|
+
'repository_unresolved',
|
|
16
|
+
'workflow_not_found',
|
|
17
|
+
'commit_mismatch',
|
|
18
|
+
'not_configured',
|
|
19
|
+
'not_verified'
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function parseGitHubRemote(remote = '') {
|
|
23
|
+
const value = remote.trim();
|
|
24
|
+
if (!value) return null;
|
|
25
|
+
|
|
26
|
+
const ssh = value.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
27
|
+
if (ssh) return `${ssh[1]}/${ssh[2].replace(/\.git$/i, '')}`;
|
|
28
|
+
|
|
29
|
+
const sshUrl = value.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
30
|
+
if (sshUrl) return `${sshUrl[1]}/${sshUrl[2].replace(/\.git$/i, '')}`;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(value);
|
|
34
|
+
if (url.hostname.toLowerCase() !== 'github.com') return null;
|
|
35
|
+
const parts = url.pathname.replace(/^\/|\.git$/g, '').split('/');
|
|
36
|
+
if (parts.length >= 2 && parts[0] && parts[1]) return `${parts[0]}/${parts[1]}`;
|
|
37
|
+
} catch {
|
|
38
|
+
// Not a URL shape.
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeConclusion(conclusion) {
|
|
45
|
+
return String(conclusion || '').toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeStatus(status) {
|
|
49
|
+
return String(status || '').toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseJsonArray(text) {
|
|
53
|
+
const parsed = JSON.parse(text || '[]');
|
|
54
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseJsonObject(text) {
|
|
58
|
+
const parsed = JSON.parse(text || '{}');
|
|
59
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isAuthOrCliFailure(result) {
|
|
63
|
+
const text = `${result.stderr || ''}\n${result.stdout || ''}`;
|
|
64
|
+
return result.exitCode === 127
|
|
65
|
+
|| /not found|could not resolve to a repository|authentication|not logged in|requires authentication|HTTP 401|HTTP 403/i.test(text);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function runState(run) {
|
|
69
|
+
const status = normalizeStatus(run.status);
|
|
70
|
+
const conclusion = normalizeConclusion(run.conclusion);
|
|
71
|
+
if (status && !TERMINAL_STATES.has(status)) return 'in_progress';
|
|
72
|
+
if (['queued', 'in_progress', 'requested', 'waiting', 'pending'].includes(status)) return 'in_progress';
|
|
73
|
+
if (conclusion === 'success') return 'verified_success';
|
|
74
|
+
if (conclusion) return 'verified_failure';
|
|
75
|
+
return status === 'completed' ? 'not_verified' : 'in_progress';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function selectGitHubRun(runs = [], { commitSha, workflow } = {}) {
|
|
79
|
+
const exact = runs.filter((run) => run.headSha === commitSha);
|
|
80
|
+
if (!exact.length) {
|
|
81
|
+
return {
|
|
82
|
+
state: runs.length ? 'commit_mismatch' : 'no_run_for_commit',
|
|
83
|
+
run: runs[0] || null
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const workflowMatches = workflow
|
|
88
|
+
? exact.filter((run) => String(run.workflowName || '') === workflow || String(run.name || '') === workflow)
|
|
89
|
+
: exact;
|
|
90
|
+
|
|
91
|
+
if (!workflowMatches.length) {
|
|
92
|
+
return { state: 'workflow_not_found', run: exact[0] || null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const completed = workflowMatches
|
|
96
|
+
.filter((run) => normalizeStatus(run.status) === 'completed' || normalizeConclusion(run.conclusion))
|
|
97
|
+
.sort((a, b) => Date.parse(b.updatedAt || b.createdAt || 0) - Date.parse(a.updatedAt || a.createdAt || 0));
|
|
98
|
+
|
|
99
|
+
const active = workflowMatches
|
|
100
|
+
.filter((run) => !completed.includes(run))
|
|
101
|
+
.sort((a, b) => Date.parse(b.updatedAt || b.createdAt || 0) - Date.parse(a.updatedAt || a.createdAt || 0));
|
|
102
|
+
|
|
103
|
+
const selected = completed[0] || active[0] || workflowMatches[0];
|
|
104
|
+
return { state: runState(selected), run: selected };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function githubStatusForState(state) {
|
|
108
|
+
if (state === 'verified_success') return 'pass';
|
|
109
|
+
if (state === 'verified_failure') return 'fail';
|
|
110
|
+
if (state === 'in_progress') return 'warn';
|
|
111
|
+
if (state === 'not_configured' || state === 'no_run_for_commit' || state === 'workflow_not_found') return 'skipped';
|
|
112
|
+
return 'not_verified';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stateLabel(state) {
|
|
116
|
+
return state.replace(/_/g, ' ');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function jobSummary(jobs = []) {
|
|
120
|
+
return jobs.map((job) => ({
|
|
121
|
+
name: job.name,
|
|
122
|
+
status: job.status,
|
|
123
|
+
conclusion: job.conclusion,
|
|
124
|
+
startedAt: job.startedAt,
|
|
125
|
+
completedAt: job.completedAt
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function defaultGhRunner(args, cwd) {
|
|
130
|
+
return runCommand('gh', args, { cwd, timeoutMs: 30000, redactStdout: false, redactStderr: true });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function resolveGitHubContext({ cwd, commitSha, remoteUrl } = {}) {
|
|
134
|
+
const boundary = await resolveProjectBoundary(cwd || process.cwd());
|
|
135
|
+
const root = boundary.root;
|
|
136
|
+
const sha = commitSha || await gitText(['rev-parse', 'HEAD'], root, { redactStdout: false });
|
|
137
|
+
const remote = remoteUrl || await gitText(['remote', 'get-url', 'origin'], root, { redactStdout: false });
|
|
138
|
+
const repository = parseGitHubRemote(remote || '');
|
|
139
|
+
return { boundary, root, commitSha: sha || null, remoteUrl: remote || null, repository };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function baseData({ state, context, workflow, run, jobs, exactCommitMatch }) {
|
|
143
|
+
return {
|
|
144
|
+
githubCi: {
|
|
145
|
+
state,
|
|
146
|
+
repository: context.repository,
|
|
147
|
+
localCommitSha: context.commitSha,
|
|
148
|
+
workflow: workflow || null,
|
|
149
|
+
runId: run?.databaseId || run?.id || null,
|
|
150
|
+
runUrl: run?.url || null,
|
|
151
|
+
event: run?.event || null,
|
|
152
|
+
status: run?.status || null,
|
|
153
|
+
conclusion: run?.conclusion || null,
|
|
154
|
+
workflowName: run?.workflowName || null,
|
|
155
|
+
headSha: run?.headSha || null,
|
|
156
|
+
exactCommitMatch,
|
|
157
|
+
createdAt: run?.createdAt || null,
|
|
158
|
+
updatedAt: run?.updatedAt || null,
|
|
159
|
+
jobs: jobSummary(jobs)
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function resultForState({ state, context, workflow, run = null, jobs = [], details = [] }) {
|
|
165
|
+
const exactCommitMatch = Boolean(run?.headSha && context.commitSha && run.headSha === context.commitSha);
|
|
166
|
+
const checks = [];
|
|
167
|
+
const verified = [];
|
|
168
|
+
const warnings = [];
|
|
169
|
+
const failures = [];
|
|
170
|
+
const skipped = [];
|
|
171
|
+
const notVerified = [];
|
|
172
|
+
const findings = [];
|
|
173
|
+
|
|
174
|
+
checks.push({
|
|
175
|
+
name: 'github repository resolution',
|
|
176
|
+
status: context.repository ? 'pass' : 'not_verified',
|
|
177
|
+
message: context.repository || 'origin remote did not resolve to GitHub'
|
|
178
|
+
});
|
|
179
|
+
checks.push({
|
|
180
|
+
name: 'github ci exact commit',
|
|
181
|
+
status: state === 'verified_success' ? 'pass' : state === 'verified_failure' ? 'fail' : githubStatusForState(state),
|
|
182
|
+
message: `${stateLabel(state)}${workflow ? `; workflow=${workflow}` : ''}`
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (state === 'verified_success') {
|
|
186
|
+
verified.push(`GitHub repository resolved: ${context.repository}`);
|
|
187
|
+
verified.push(`Exact local commit matched GitHub Actions run: ${context.commitSha}`);
|
|
188
|
+
verified.push(`Workflow run succeeded: ${run.workflowName || workflow || 'not specified'}`);
|
|
189
|
+
if (jobs.length) verified.push(`Workflow jobs inspected: ${jobs.map((job) => `${job.name}:${job.conclusion || job.status}`).join(', ')}`);
|
|
190
|
+
} else if (state === 'verified_failure') {
|
|
191
|
+
failures.push(`GitHub Actions run for exact commit did not succeed: ${run?.conclusion || run?.status || 'unknown'}`);
|
|
192
|
+
findings.push(createFinding({
|
|
193
|
+
status: 'fail',
|
|
194
|
+
area: 'github',
|
|
195
|
+
title: 'GitHub Actions exact-commit run failed',
|
|
196
|
+
finding: `GitHub Actions run for ${context.commitSha || 'current commit'} did not succeed.`,
|
|
197
|
+
evidence: [
|
|
198
|
+
`repository: ${context.repository || 'unresolved'}`,
|
|
199
|
+
`workflow: ${run?.workflowName || workflow || 'not specified'}`,
|
|
200
|
+
`run id: ${run?.databaseId || run?.id || 'not available'}`,
|
|
201
|
+
`status: ${run?.status || 'unknown'}`,
|
|
202
|
+
`conclusion: ${run?.conclusion || 'unknown'}`
|
|
203
|
+
],
|
|
204
|
+
whyItMatters: 'CI failure is local/hosted quality evidence for the exact commit, not a deploy or production proof.',
|
|
205
|
+
nextSafeStep: 'Inspect the failing check in GitHub and fix it before relying on this commit.'
|
|
206
|
+
}));
|
|
207
|
+
} else if (state === 'in_progress') {
|
|
208
|
+
warnings.push('GitHub Actions run for the exact commit is still in progress');
|
|
209
|
+
} else if (state === 'repository_unresolved') {
|
|
210
|
+
notVerified.push('GitHub repository could not be resolved from origin remote');
|
|
211
|
+
} else if (state === 'authentication_unavailable') {
|
|
212
|
+
notVerified.push('GitHub authentication or gh CLI was unavailable');
|
|
213
|
+
} else if (state === 'workflow_not_found') {
|
|
214
|
+
skipped.push(`No GitHub Actions run matched workflow ${workflow}`);
|
|
215
|
+
notVerified.push('Configured GitHub Actions workflow was not verified for this commit');
|
|
216
|
+
} else if (state === 'no_run_for_commit') {
|
|
217
|
+
skipped.push('No GitHub Actions run was found for the current local commit');
|
|
218
|
+
notVerified.push('GitHub CI did not provide proof for this checkout');
|
|
219
|
+
} else if (state === 'commit_mismatch') {
|
|
220
|
+
notVerified.push('GitHub Actions metadata did not match the current local commit');
|
|
221
|
+
} else {
|
|
222
|
+
notVerified.push('GitHub Actions proof was not verified');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const detail of details) notVerified.push(detail);
|
|
226
|
+
|
|
227
|
+
return createResult('github-ci', githubStatusForState(state), {
|
|
228
|
+
summary: 'Read-only GitHub Actions exact-commit proof check completed.',
|
|
229
|
+
verified,
|
|
230
|
+
warnings,
|
|
231
|
+
failures,
|
|
232
|
+
skipped,
|
|
233
|
+
notVerified: [
|
|
234
|
+
...notVerified,
|
|
235
|
+
'GitHub CI does not prove production deployment, Supabase state, scheduler state, production headers, or function behavior'
|
|
236
|
+
],
|
|
237
|
+
findings,
|
|
238
|
+
checks,
|
|
239
|
+
data: baseData({ state, context, workflow, run, jobs, exactCommitMatch }),
|
|
240
|
+
nextSafeStep: state === 'verified_success'
|
|
241
|
+
? 'Treat CI as hosted quality proof only; verify production separately when needed.'
|
|
242
|
+
: 'Resolve the GitHub CI proof gap or inspect the matching workflow in GitHub.'
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function runGitHubCi({
|
|
247
|
+
cwd = process.cwd(),
|
|
248
|
+
workflow,
|
|
249
|
+
ghRunner = defaultGhRunner,
|
|
250
|
+
commitSha,
|
|
251
|
+
remoteUrl
|
|
252
|
+
} = {}) {
|
|
253
|
+
const loaded = await loadOpstruthConfig(cwd);
|
|
254
|
+
const configuredWorkflow = workflow || loaded.config?.github?.ci?.workflow;
|
|
255
|
+
let context;
|
|
256
|
+
try {
|
|
257
|
+
context = await resolveGitHubContext({ cwd, commitSha, remoteUrl });
|
|
258
|
+
} catch (error) {
|
|
259
|
+
context = { root: cwd, commitSha: commitSha || null, remoteUrl: remoteUrl || null, repository: null };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!context.repository || !context.commitSha) {
|
|
263
|
+
return resultForState({
|
|
264
|
+
state: 'repository_unresolved',
|
|
265
|
+
context,
|
|
266
|
+
workflow: configuredWorkflow,
|
|
267
|
+
details: loaded.warning ? [loaded.warning] : []
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const listArgs = [
|
|
272
|
+
'run',
|
|
273
|
+
'list',
|
|
274
|
+
'--repo',
|
|
275
|
+
context.repository,
|
|
276
|
+
'--commit',
|
|
277
|
+
context.commitSha,
|
|
278
|
+
'--limit',
|
|
279
|
+
'20',
|
|
280
|
+
'--json',
|
|
281
|
+
'databaseId,headSha,conclusion,status,event,createdAt,updatedAt,workflowName,url'
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
const list = await ghRunner(listArgs, context.root);
|
|
285
|
+
if (list.exitCode !== 0) {
|
|
286
|
+
const state = isAuthOrCliFailure(list) ? 'authentication_unavailable' : 'not_verified';
|
|
287
|
+
return resultForState({ state, context, workflow: configuredWorkflow, details: [list.stderr || list.stdout || 'GitHub CLI query failed'] });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let runs;
|
|
291
|
+
try {
|
|
292
|
+
runs = parseJsonArray(list.stdout);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return resultForState({ state: 'not_verified', context, workflow: configuredWorkflow, details: ['GitHub CLI returned malformed workflow JSON'] });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const selection = selectGitHubRun(runs, { commitSha: context.commitSha, workflow: configuredWorkflow });
|
|
298
|
+
if (!selection.run || !['verified_success', 'verified_failure', 'in_progress'].includes(selection.state)) {
|
|
299
|
+
return resultForState({ state: selection.state, context, workflow: configuredWorkflow, run: selection.run });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const runId = selection.run.databaseId || selection.run.id;
|
|
303
|
+
if (!runId) return resultForState({ state: selection.state, context, workflow: configuredWorkflow, run: selection.run });
|
|
304
|
+
|
|
305
|
+
const view = await ghRunner([
|
|
306
|
+
'run',
|
|
307
|
+
'view',
|
|
308
|
+
String(runId),
|
|
309
|
+
'--repo',
|
|
310
|
+
context.repository,
|
|
311
|
+
'--json',
|
|
312
|
+
'databaseId,conclusion,status,event,headSha,workflowName,jobs,url,createdAt,updatedAt'
|
|
313
|
+
], context.root);
|
|
314
|
+
|
|
315
|
+
if (view.exitCode !== 0) {
|
|
316
|
+
const state = isAuthOrCliFailure(view) ? 'authentication_unavailable' : selection.state;
|
|
317
|
+
return resultForState({ state, context, workflow: configuredWorkflow, run: selection.run, details: [view.stderr || view.stdout || 'GitHub CLI run detail query failed'] });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let run;
|
|
321
|
+
try {
|
|
322
|
+
run = parseJsonObject(view.stdout);
|
|
323
|
+
} catch {
|
|
324
|
+
return resultForState({ state: 'not_verified', context, workflow: configuredWorkflow, run: selection.run, details: ['GitHub CLI returned malformed run detail JSON'] });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (run.headSha !== context.commitSha) {
|
|
328
|
+
return resultForState({ state: 'commit_mismatch', context, workflow: configuredWorkflow, run });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return resultForState({
|
|
332
|
+
state: runState(run),
|
|
333
|
+
context,
|
|
334
|
+
workflow: configuredWorkflow,
|
|
335
|
+
run,
|
|
336
|
+
jobs: Array.isArray(run.jobs) ? run.jobs : []
|
|
337
|
+
});
|
|
338
|
+
}
|