@marcusrbrown/infra 0.4.8 → 0.4.10
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/package.json +2 -2
- package/src/__snapshots__/cli.test.ts.snap +32 -1
- package/src/cli.ts +2 -0
- package/src/commands/gateway/backup.test.ts +288 -0
- package/src/commands/gateway/backup.ts +188 -0
- package/src/commands/gateway/deploy.test.ts +297 -0
- package/src/commands/gateway/deploy.ts +148 -0
- package/src/commands/gateway/host.test.ts +73 -0
- package/src/commands/gateway/host.ts +31 -0
- package/src/commands/gateway/index.ts +17 -0
- package/src/commands/gateway/logs.test.ts +222 -0
- package/src/commands/gateway/logs.ts +158 -0
- package/src/commands/gateway/restore.test.ts +494 -0
- package/src/commands/gateway/restore.ts +297 -0
- package/src/commands/gateway/status.test.ts +259 -0
- package/src/commands/gateway/status.ts +209 -0
- package/src/commands/status.test.ts +25 -0
- package/src/commands/status.ts +13 -3
- package/src/conventions.test.ts +215 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type {goke} from 'goke'
|
|
2
|
+
import type {StatusSummary} from '../status'
|
|
3
|
+
|
|
4
|
+
import {z} from 'zod'
|
|
5
|
+
|
|
6
|
+
import {validateGatewayHost} from './host'
|
|
7
|
+
|
|
8
|
+
declare const process: {
|
|
9
|
+
env: Record<string, string | undefined>
|
|
10
|
+
exitCode?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const COMPOSE_PROJECT_DIR = '/opt/gateway/deploy'
|
|
14
|
+
|
|
15
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export interface ComposePsEntry {
|
|
18
|
+
Name: string
|
|
19
|
+
State: string
|
|
20
|
+
Health: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type HealthStatus = 'healthy' | 'unhealthy' | 'starting' | 'n-a'
|
|
24
|
+
|
|
25
|
+
export interface ServiceRow {
|
|
26
|
+
service: string
|
|
27
|
+
state: string
|
|
28
|
+
health: HealthStatus
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GatewayStatusResult {
|
|
32
|
+
ok: boolean
|
|
33
|
+
services: ServiceRow[]
|
|
34
|
+
error?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type SpawnFn = (
|
|
38
|
+
cmd: string[],
|
|
39
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
40
|
+
) => {
|
|
41
|
+
stdout: ReadableStream<Uint8Array>
|
|
42
|
+
stderr: ReadableStream<Uint8Array>
|
|
43
|
+
exited: Promise<number>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
function normalizeHealth(raw: string): HealthStatus {
|
|
49
|
+
if (raw === 'healthy' || raw === 'unhealthy' || raw === 'starting') {
|
|
50
|
+
return raw
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return 'n-a'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseComposePs(entries: ComposePsEntry[]): ServiceRow[] {
|
|
57
|
+
return entries.map(entry => ({
|
|
58
|
+
service: entry.Name,
|
|
59
|
+
state: entry.State,
|
|
60
|
+
// Workspace has no upstream healthcheck in v1; health will upgrade automatically
|
|
61
|
+
// when upstream adds one and docker compose ps starts reporting it.
|
|
62
|
+
health: normalizeHealth(entry.Health),
|
|
63
|
+
}))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isAllRunning(rows: ServiceRow[]): boolean {
|
|
67
|
+
return rows.every(row => row.state === 'running')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ─── SSH-backed status fetch ──────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function defaultSpawn(
|
|
73
|
+
cmd: string[],
|
|
74
|
+
opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
|
|
75
|
+
): ReturnType<SpawnFn> {
|
|
76
|
+
return Bun.spawn(cmd, opts) as ReturnType<SpawnFn>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function getGatewayComposeStatus(
|
|
80
|
+
host: string,
|
|
81
|
+
spawn: SpawnFn = defaultSpawn,
|
|
82
|
+
): Promise<GatewayStatusResult> {
|
|
83
|
+
validateGatewayHost(host)
|
|
84
|
+
|
|
85
|
+
const sshCmd = [
|
|
86
|
+
'ssh',
|
|
87
|
+
'-o',
|
|
88
|
+
'BatchMode=yes',
|
|
89
|
+
'-o',
|
|
90
|
+
'StrictHostKeyChecking=yes',
|
|
91
|
+
`root@${host}`,
|
|
92
|
+
`docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
const env: Record<string, string> = {
|
|
96
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
97
|
+
HOME: process.env.HOME ?? '/tmp',
|
|
98
|
+
...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
|
|
102
|
+
|
|
103
|
+
const [stdoutText, stderrText, exitCode] = await Promise.all([
|
|
104
|
+
new Response(child.stdout).text(),
|
|
105
|
+
new Response(child.stderr).text(),
|
|
106
|
+
child.exited,
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
if (exitCode !== 0) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
services: [],
|
|
113
|
+
error: `SSH command failed (exit ${exitCode}): ${stderrText.trim() || 'unknown error'}`,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let entries: ComposePsEntry[]
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const parsed: unknown = JSON.parse(stdoutText)
|
|
121
|
+
entries = Array.isArray(parsed) ? (parsed as ComposePsEntry[]) : []
|
|
122
|
+
} catch {
|
|
123
|
+
return {ok: false, services: [], error: `Failed to parse docker compose ps output: ${stdoutText.slice(0, 200)}`}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const services = parseComposePs(entries)
|
|
127
|
+
const ok = isAllRunning(services)
|
|
128
|
+
|
|
129
|
+
return {ok, services}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Unified status aggregator export ────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export async function getGatewayStatusSummary(host: string): Promise<StatusSummary> {
|
|
135
|
+
const result = await getGatewayComposeStatus(host)
|
|
136
|
+
|
|
137
|
+
if (!result.ok || result.services.length === 0) {
|
|
138
|
+
const errorMsg = result.error ?? 'No services reported'
|
|
139
|
+
return {
|
|
140
|
+
app: 'gateway',
|
|
141
|
+
http: `ERROR: ${errorMsg}`,
|
|
142
|
+
lastDeploy: '—',
|
|
143
|
+
version: '—',
|
|
144
|
+
contentHash: '—',
|
|
145
|
+
usageStats: '—',
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const rows = result.services.map(s => `${s.service}:${s.state}/${s.health}`).join(', ')
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
app: 'gateway',
|
|
153
|
+
http: `OK: ${rows}`,
|
|
154
|
+
lastDeploy: '—',
|
|
155
|
+
version: '—',
|
|
156
|
+
contentHash: '—',
|
|
157
|
+
usageStats: '—',
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Command registration ─────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export function registerGatewayStatus(cli: ReturnType<typeof goke>): void {
|
|
164
|
+
cli
|
|
165
|
+
.command('gateway status', 'Show operational health of the gateway deployment via docker compose ps.')
|
|
166
|
+
.option(
|
|
167
|
+
'--key [key]',
|
|
168
|
+
z.string().describe('Environment variable name holding the SSH host. Falls back to GATEWAY_HOST when omitted.'),
|
|
169
|
+
)
|
|
170
|
+
.action(async options => {
|
|
171
|
+
const hostEnvKey = options.key ?? 'GATEWAY_HOST'
|
|
172
|
+
const host = process.env[hostEnvKey]
|
|
173
|
+
|
|
174
|
+
if (!host) {
|
|
175
|
+
console.error(`Gateway host not set. Export ${hostEnvKey} or pass --key <env-name> pointing to a set variable.`)
|
|
176
|
+
process.exitCode = 1
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
console.log('Gateway status')
|
|
181
|
+
console.log('')
|
|
182
|
+
|
|
183
|
+
const result = await getGatewayComposeStatus(host)
|
|
184
|
+
|
|
185
|
+
if (!result.ok && result.services.length === 0) {
|
|
186
|
+
console.error(`Error: ${result.error ?? 'Unknown error'}`)
|
|
187
|
+
process.exitCode = 1
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
console.log('Service State Health')
|
|
192
|
+
console.log('─────────────────────────────────────')
|
|
193
|
+
|
|
194
|
+
for (const row of result.services) {
|
|
195
|
+
const svc = row.service.padEnd(16)
|
|
196
|
+
const state = row.state.padEnd(10)
|
|
197
|
+
console.log(`${svc} ${state} ${row.health}`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
console.log('')
|
|
201
|
+
|
|
202
|
+
if (result.ok) {
|
|
203
|
+
console.log('Status: OK')
|
|
204
|
+
} else {
|
|
205
|
+
console.log('Status: DEGRADED (one or more services not running)')
|
|
206
|
+
process.exitCode = 1
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
}
|
|
@@ -50,11 +50,20 @@ describe('top-level status command', () => {
|
|
|
50
50
|
contentHash: '—',
|
|
51
51
|
usageStats: '12 req / 0 fail',
|
|
52
52
|
}),
|
|
53
|
+
getGatewayStatusSummary: async () => ({
|
|
54
|
+
app: 'gateway',
|
|
55
|
+
http: 'OK: gateway:running/healthy',
|
|
56
|
+
lastDeploy: '—',
|
|
57
|
+
version: '—',
|
|
58
|
+
contentHash: '—',
|
|
59
|
+
usageStats: '—',
|
|
60
|
+
}),
|
|
53
61
|
})
|
|
54
62
|
|
|
55
63
|
expect(lines).toContain('| App | HTTP | Last Deploy | Version | Content Hash | Usage Stats |')
|
|
56
64
|
expect(lines).toContain('| keeweb | OK | 2026-04-12 10:00 | — | match | — |')
|
|
57
65
|
expect(lines).toContain('| cliproxy | OK | — | v1.2.3 | — | 12 req / 0 fail |')
|
|
66
|
+
expect(lines).toContain('| gateway | OK: gateway:running/healthy | — | — | — | — |')
|
|
58
67
|
})
|
|
59
68
|
|
|
60
69
|
it('shows an error row when one app fails and keeps the other result', async () => {
|
|
@@ -70,6 +79,14 @@ describe('top-level status command', () => {
|
|
|
70
79
|
contentHash: '—',
|
|
71
80
|
usageStats: '12 req / 0 fail',
|
|
72
81
|
}),
|
|
82
|
+
getGatewayStatusSummary: async () => ({
|
|
83
|
+
app: 'gateway',
|
|
84
|
+
http: 'OK: gateway:running/healthy',
|
|
85
|
+
lastDeploy: '—',
|
|
86
|
+
version: '—',
|
|
87
|
+
contentHash: '—',
|
|
88
|
+
usageStats: '—',
|
|
89
|
+
}),
|
|
73
90
|
})
|
|
74
91
|
|
|
75
92
|
expect(lines).toContain(
|
|
@@ -96,6 +113,14 @@ describe('top-level status command', () => {
|
|
|
96
113
|
contentHash: '—',
|
|
97
114
|
usageStats: '12 req / 0 fail',
|
|
98
115
|
}),
|
|
116
|
+
getGatewayStatusSummary: async () => ({
|
|
117
|
+
app: 'gateway',
|
|
118
|
+
http: 'OK: gateway:running/healthy',
|
|
119
|
+
lastDeploy: '—',
|
|
120
|
+
version: '—',
|
|
121
|
+
contentHash: '—',
|
|
122
|
+
usageStats: '—',
|
|
123
|
+
}),
|
|
99
124
|
})
|
|
100
125
|
|
|
101
126
|
expect(lines).toHaveLength(1)
|
package/src/commands/status.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {goke} from 'goke'
|
|
|
3
3
|
import {z} from 'zod'
|
|
4
4
|
|
|
5
5
|
import {getCliproxyStatusSummary} from './cliproxy/status'
|
|
6
|
+
import {getGatewayStatusSummary} from './gateway'
|
|
6
7
|
import {getKeewebStatusSummary} from './keeweb/status'
|
|
7
8
|
|
|
8
9
|
declare const process: {
|
|
@@ -10,7 +11,7 @@ declare const process: {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export interface StatusSummary {
|
|
13
|
-
app: 'keeweb' | 'cliproxy'
|
|
14
|
+
app: 'keeweb' | 'cliproxy' | 'gateway'
|
|
14
15
|
http: string
|
|
15
16
|
lastDeploy: string
|
|
16
17
|
version: string
|
|
@@ -23,6 +24,7 @@ type AppName = StatusSummary['app']
|
|
|
23
24
|
interface StatusDependencies {
|
|
24
25
|
getKeewebStatusSummary: (verbose: boolean) => Promise<StatusSummary>
|
|
25
26
|
getCliproxyStatusSummary: (baseUrl: string, key: string, verbose: boolean) => Promise<StatusSummary>
|
|
27
|
+
getGatewayStatusSummary: (host: string) => Promise<StatusSummary>
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const DEFAULT_CLIPROXY_URL = 'https://cliproxy.fro.bot'
|
|
@@ -58,6 +60,7 @@ function toJsonPayload(rows: StatusSummary[]): Record<AppName, StatusSummary> {
|
|
|
58
60
|
return {
|
|
59
61
|
keeweb: rows.find(row => row.app === 'keeweb') ?? errorSummary('keeweb', 'missing result'),
|
|
60
62
|
cliproxy: rows.find(row => row.app === 'cliproxy') ?? errorSummary('cliproxy', 'missing result'),
|
|
63
|
+
gateway: rows.find(row => row.app === 'gateway') ?? errorSummary('gateway', 'missing result'),
|
|
61
64
|
}
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -71,11 +74,15 @@ export function registerStatus(
|
|
|
71
74
|
dependencies: StatusDependencies = {
|
|
72
75
|
getKeewebStatusSummary,
|
|
73
76
|
getCliproxyStatusSummary,
|
|
77
|
+
getGatewayStatusSummary,
|
|
74
78
|
},
|
|
75
79
|
): void {
|
|
76
80
|
cli
|
|
77
81
|
.command('status', 'Show status of all deployments')
|
|
78
|
-
.option(
|
|
82
|
+
.option(
|
|
83
|
+
'--json',
|
|
84
|
+
z.boolean().describe('Output machine-readable JSON with keeweb, cliproxy, and gateway summary objects.'),
|
|
85
|
+
)
|
|
79
86
|
.option(
|
|
80
87
|
'--verbose',
|
|
81
88
|
z.boolean().describe('Include verbose per-app health check details when building the summary rows.'),
|
|
@@ -84,14 +91,17 @@ export function registerStatus(
|
|
|
84
91
|
const verbose = options.verbose === true
|
|
85
92
|
const cliproxyBaseUrl = stripTrailingSlash(process.env.CLIPROXY_URL ?? DEFAULT_CLIPROXY_URL)
|
|
86
93
|
const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? ''
|
|
94
|
+
const gatewayHost = process.env.GATEWAY_HOST ?? ''
|
|
87
95
|
|
|
88
96
|
const results = await Promise.allSettled([
|
|
89
97
|
dependencies.getKeewebStatusSummary(verbose),
|
|
90
98
|
dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, verbose),
|
|
99
|
+
dependencies.getGatewayStatusSummary(gatewayHost),
|
|
91
100
|
])
|
|
92
101
|
|
|
102
|
+
const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway']
|
|
93
103
|
const rows: StatusSummary[] = results.map((result, index) => {
|
|
94
|
-
const app
|
|
104
|
+
const app = appNames[index] ?? 'keeweb'
|
|
95
105
|
return result.status === 'fulfilled' ? result.value : errorSummary(app, result.reason)
|
|
96
106
|
})
|
|
97
107
|
|
package/src/conventions.test.ts
CHANGED
|
@@ -71,6 +71,80 @@ export function findCrossOrgSecretsInherit(parsed: unknown): {jobId: string; use
|
|
|
71
71
|
return violations
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Detect dorny/paths-filter steps that use negation patterns without declaring
|
|
76
|
+
* `predicate-quantifier: every`. The default quantifier (`some`) applies OR-logic
|
|
77
|
+
* across patterns, which silently makes negations truthy whenever any other file
|
|
78
|
+
* matches — the opposite of the intended behaviour.
|
|
79
|
+
*
|
|
80
|
+
* Rule: any step using dorny/paths-filter that contains a filter pattern starting
|
|
81
|
+
* with `!` MUST also set `predicate-quantifier: every` in the same step's `with:` block.
|
|
82
|
+
*/
|
|
83
|
+
export interface PathsFilterQuantifierViolation {
|
|
84
|
+
file?: string
|
|
85
|
+
jobId: string
|
|
86
|
+
stepIndex: number
|
|
87
|
+
reason: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function findPathsFilterQuantifierViolations(workflowText: string): PathsFilterQuantifierViolation[] {
|
|
91
|
+
const parsed = parseYaml(workflowText, {merge: true}) as unknown
|
|
92
|
+
if (typeof parsed !== 'object' || parsed === null) return []
|
|
93
|
+
const jobs = (parsed as {jobs?: Record<string, unknown>}).jobs
|
|
94
|
+
if (typeof jobs !== 'object' || jobs === null) return []
|
|
95
|
+
|
|
96
|
+
const violations: PathsFilterQuantifierViolation[] = []
|
|
97
|
+
|
|
98
|
+
for (const [jobId, jobRaw] of Object.entries(jobs)) {
|
|
99
|
+
if (typeof jobRaw !== 'object' || jobRaw === null) continue
|
|
100
|
+
const job = jobRaw as {steps?: unknown[]}
|
|
101
|
+
if (!Array.isArray(job.steps)) continue
|
|
102
|
+
|
|
103
|
+
for (const [index, stepRaw] of job.steps.entries()) {
|
|
104
|
+
if (typeof stepRaw !== 'object' || stepRaw === null) continue
|
|
105
|
+
const step = stepRaw as {uses?: unknown; with?: Record<string, unknown>}
|
|
106
|
+
if (typeof step.uses !== 'string') continue
|
|
107
|
+
if (!step.uses.startsWith('dorny/paths-filter')) continue
|
|
108
|
+
|
|
109
|
+
const withBlock = step.with ?? {}
|
|
110
|
+
const filtersRaw = withBlock.filters
|
|
111
|
+
if (typeof filtersRaw !== 'string') continue
|
|
112
|
+
|
|
113
|
+
// Parse the inner YAML of the filters block to inspect pattern lists
|
|
114
|
+
const filters = parseYaml(filtersRaw, {merge: true}) as unknown
|
|
115
|
+
if (typeof filters !== 'object' || filters === null) continue
|
|
116
|
+
|
|
117
|
+
let hasNegation = false
|
|
118
|
+
for (const patternsRaw of Object.values(filters as Record<string, unknown>)) {
|
|
119
|
+
const patterns = Array.isArray(patternsRaw) ? patternsRaw : [patternsRaw]
|
|
120
|
+
for (const p of patterns) {
|
|
121
|
+
if (typeof p === 'string' && p.startsWith('!')) {
|
|
122
|
+
hasNegation = true
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (hasNegation) break
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!hasNegation) continue
|
|
130
|
+
|
|
131
|
+
const quantifier = withBlock['predicate-quantifier']
|
|
132
|
+
if (quantifier !== 'every') {
|
|
133
|
+
violations.push({
|
|
134
|
+
jobId,
|
|
135
|
+
stepIndex: index,
|
|
136
|
+
reason:
|
|
137
|
+
quantifier === undefined
|
|
138
|
+
? `job '${jobId}' step ${index} uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`
|
|
139
|
+
: `job '${jobId}' step ${index} uses dorny/paths-filter with negation patterns but predicate-quantifier is '${String(quantifier)}' (must be 'every')`,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return violations
|
|
146
|
+
}
|
|
147
|
+
|
|
74
148
|
describe('repo conventions', () => {
|
|
75
149
|
it('tripwire: workflow glob resolves to at least one file (catches dot-dir glob regressions)', () => {
|
|
76
150
|
const workflows = listWorkflowFiles('.yaml')
|
|
@@ -314,3 +388,144 @@ jobs:
|
|
|
314
388
|
expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
|
|
315
389
|
})
|
|
316
390
|
})
|
|
391
|
+
|
|
392
|
+
describe('findPathsFilterQuantifierViolations', () => {
|
|
393
|
+
it('paths-filter with negations and predicate-quantifier: every → 0 violations', () => {
|
|
394
|
+
const yaml = `
|
|
395
|
+
jobs:
|
|
396
|
+
detect:
|
|
397
|
+
runs-on: ubuntu-latest
|
|
398
|
+
steps:
|
|
399
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
400
|
+
with:
|
|
401
|
+
predicate-quantifier: every
|
|
402
|
+
filters: |
|
|
403
|
+
app:
|
|
404
|
+
- 'apps/myapp/**'
|
|
405
|
+
- '!apps/myapp/**/*.md'
|
|
406
|
+
`
|
|
407
|
+
expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('paths-filter with negations and missing predicate-quantifier → 1 violation', () => {
|
|
411
|
+
const yaml = `
|
|
412
|
+
jobs:
|
|
413
|
+
detect:
|
|
414
|
+
runs-on: ubuntu-latest
|
|
415
|
+
steps:
|
|
416
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
417
|
+
with:
|
|
418
|
+
filters: |
|
|
419
|
+
app:
|
|
420
|
+
- 'apps/myapp/**'
|
|
421
|
+
- '!apps/myapp/**/*.md'
|
|
422
|
+
`
|
|
423
|
+
const violations = findPathsFilterQuantifierViolations(yaml)
|
|
424
|
+
expect(violations).toEqual([
|
|
425
|
+
{
|
|
426
|
+
jobId: 'detect',
|
|
427
|
+
stepIndex: 0,
|
|
428
|
+
reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`,
|
|
429
|
+
},
|
|
430
|
+
])
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('paths-filter with negations and predicate-quantifier: some → 1 violation', () => {
|
|
434
|
+
const yaml = `
|
|
435
|
+
jobs:
|
|
436
|
+
detect:
|
|
437
|
+
runs-on: ubuntu-latest
|
|
438
|
+
steps:
|
|
439
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
440
|
+
with:
|
|
441
|
+
predicate-quantifier: some
|
|
442
|
+
filters: |
|
|
443
|
+
app:
|
|
444
|
+
- 'apps/myapp/**'
|
|
445
|
+
- '!apps/myapp/**/*.md'
|
|
446
|
+
`
|
|
447
|
+
const violations = findPathsFilterQuantifierViolations(yaml)
|
|
448
|
+
expect(violations).toEqual([
|
|
449
|
+
{
|
|
450
|
+
jobId: 'detect',
|
|
451
|
+
stepIndex: 0,
|
|
452
|
+
reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but predicate-quantifier is 'some' (must be 'every')`,
|
|
453
|
+
},
|
|
454
|
+
])
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('paths-filter without negations → 0 violations regardless of quantifier', () => {
|
|
458
|
+
const yaml = `
|
|
459
|
+
jobs:
|
|
460
|
+
detect:
|
|
461
|
+
runs-on: ubuntu-latest
|
|
462
|
+
steps:
|
|
463
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
464
|
+
with:
|
|
465
|
+
filters: |
|
|
466
|
+
app:
|
|
467
|
+
- 'apps/myapp/**'
|
|
468
|
+
- 'apps/myapp/**/*.ts'
|
|
469
|
+
`
|
|
470
|
+
expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('bare-string negation filter without predicate-quantifier → 1 violation', () => {
|
|
474
|
+
const yaml = `
|
|
475
|
+
jobs:
|
|
476
|
+
detect:
|
|
477
|
+
runs-on: ubuntu-latest
|
|
478
|
+
steps:
|
|
479
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
480
|
+
with:
|
|
481
|
+
filters: |
|
|
482
|
+
cliproxy: '!apps/cliproxy/**/*.md'
|
|
483
|
+
`
|
|
484
|
+
const violations = findPathsFilterQuantifierViolations(yaml)
|
|
485
|
+
expect(violations).toEqual([
|
|
486
|
+
{
|
|
487
|
+
jobId: 'detect',
|
|
488
|
+
stepIndex: 0,
|
|
489
|
+
reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`,
|
|
490
|
+
},
|
|
491
|
+
])
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('bare-string negation filter with predicate-quantifier: every → 0 violations', () => {
|
|
495
|
+
const yaml = `
|
|
496
|
+
jobs:
|
|
497
|
+
detect:
|
|
498
|
+
runs-on: ubuntu-latest
|
|
499
|
+
steps:
|
|
500
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
501
|
+
with:
|
|
502
|
+
predicate-quantifier: every
|
|
503
|
+
filters: |
|
|
504
|
+
cliproxy: '!apps/cliproxy/**/*.md'
|
|
505
|
+
`
|
|
506
|
+
expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
describe('dorny/paths-filter quantifier guard', () => {
|
|
511
|
+
it('tripwire: workflow glob resolves to at least one file (catches dot-dir glob regressions)', () => {
|
|
512
|
+
// `.github/` is a dot-directory; Bun.Glob skips dot-dirs by default unless `dot: true` is set.
|
|
513
|
+
const glob = new Bun.Glob('.github/workflows/**')
|
|
514
|
+
const files = [...glob.scanSync({cwd: REPO_ROOT, absolute: true, dot: true})]
|
|
515
|
+
expect(files.length).toBeGreaterThan(0)
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('all workflow files using dorny/paths-filter with negations declare predicate-quantifier: every', async () => {
|
|
519
|
+
const files = listWorkflowFiles('.yaml')
|
|
520
|
+
expect(files.length).toBeGreaterThan(0)
|
|
521
|
+
|
|
522
|
+
const violations: PathsFilterQuantifierViolation[] = []
|
|
523
|
+
for (const file of files) {
|
|
524
|
+
const text = await Bun.file(file).text()
|
|
525
|
+
for (const v of findPathsFilterQuantifierViolations(text)) {
|
|
526
|
+
violations.push({...v, file: relative(REPO_ROOT, file)})
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
expect(violations).toEqual([])
|
|
530
|
+
})
|
|
531
|
+
})
|