@marcusrbrown/infra 0.4.4 → 0.4.6
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 +4 -1
- package/src/__snapshots__/cli.test.ts.snap +2 -2
- package/src/commands/cliproxy/deploy.ts +3 -3
- package/src/commands/cliproxy/setup.test.ts +58 -0
- package/src/commands/cliproxy/setup.ts +103 -20
- package/src/commands/keeweb/deploy.ts +8 -6
- package/src/commands/keeweb/status.ts +2 -2
- package/src/conventions.test.ts +316 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marcusrbrown/infra",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"infra",
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
"string-dedent": "^3.0.2",
|
|
36
36
|
"zod": "^4.3.6"
|
|
37
37
|
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"yaml": "^2.8.3"
|
|
40
|
+
},
|
|
38
41
|
"engines": {
|
|
39
42
|
"bun": ">=1.0.0"
|
|
40
43
|
},
|
|
@@ -14,7 +14,7 @@ Commands:
|
|
|
14
14
|
--verbose Enable verbose output for all commands
|
|
15
15
|
|
|
16
16
|
|
|
17
|
-
keeweb deploy Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.
|
|
17
|
+
keeweb deploy Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy KeeWeb workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.
|
|
18
18
|
|
|
19
19
|
--local Run local deployment using apps/keeweb/deploy.sh instead of triggering GitHub Actions. (default: false)
|
|
20
20
|
--nginx Include nginx config deployment. Valid only with --local and passed through to deploy.sh as --nginx. (default: false)
|
|
@@ -31,7 +31,7 @@ Commands:
|
|
|
31
31
|
--verbose Enable verbose output for all commands
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
cliproxy deploy Deploy CLIProxyAPI. Default mode triggers the GitHub Deploy workflow, while --local runs apps/cliproxy/src/deploy.ts directly with Bun.
|
|
34
|
+
cliproxy deploy Deploy CLIProxyAPI. Default mode triggers the GitHub Deploy CLIProxy workflow, while --local runs apps/cliproxy/src/deploy.ts directly with Bun.
|
|
35
35
|
|
|
36
36
|
--local Run local deployment with Bun using apps/cliproxy/src/deploy.ts instead of triggering GitHub Actions. (default: false)
|
|
37
37
|
--force-config Force upload config.yaml even if it exists on the server. WARNING: overwrites runtime API keys and settings. (default: false)
|
|
@@ -5,8 +5,8 @@ import {resolve} from 'node:path'
|
|
|
5
5
|
import {z} from 'zod'
|
|
6
6
|
|
|
7
7
|
const REPO = 'marcusrbrown/infra'
|
|
8
|
-
const WORKFLOW_NAME = 'Deploy'
|
|
9
|
-
const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy.yaml'
|
|
8
|
+
const WORKFLOW_NAME = 'Deploy CLIProxy'
|
|
9
|
+
const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-cliproxy.yaml'
|
|
10
10
|
|
|
11
11
|
type CliInstance = ReturnType<typeof goke>
|
|
12
12
|
|
|
@@ -66,7 +66,7 @@ export function registerCliproxyDeploy(cli: CliInstance): void {
|
|
|
66
66
|
cli
|
|
67
67
|
.command(
|
|
68
68
|
'cliproxy deploy',
|
|
69
|
-
'Deploy CLIProxyAPI. Default mode triggers the GitHub Deploy workflow, while --local runs apps/cliproxy/src/deploy.ts directly with Bun.',
|
|
69
|
+
'Deploy CLIProxyAPI. Default mode triggers the GitHub Deploy CLIProxy workflow, while --local runs apps/cliproxy/src/deploy.ts directly with Bun.',
|
|
70
70
|
)
|
|
71
71
|
.option(
|
|
72
72
|
'--local',
|
|
@@ -8,8 +8,10 @@ import {
|
|
|
8
8
|
formatWorkflowSnippet,
|
|
9
9
|
getHarnessTemplate,
|
|
10
10
|
interpretGhContentResult,
|
|
11
|
+
isGhRateLimitError,
|
|
11
12
|
registerCliproxySetup,
|
|
12
13
|
validateSetupOptions,
|
|
14
|
+
withGhRetry,
|
|
13
15
|
type SecretAssignment,
|
|
14
16
|
type VariableAssignment,
|
|
15
17
|
} from './setup'
|
|
@@ -266,6 +268,62 @@ describe('cliproxy setup helpers', () => {
|
|
|
266
268
|
})
|
|
267
269
|
})
|
|
268
270
|
|
|
271
|
+
describe('isGhRateLimitError', () => {
|
|
272
|
+
it('returns true when text contains "rate limit"', () => {
|
|
273
|
+
expect(isGhRateLimitError('API rate limit exceeded')).toBe(true)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('is case-insensitive', () => {
|
|
277
|
+
expect(isGhRateLimitError('You have exceeded a secondary RATE LIMIT')).toBe(true)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('returns false for unrelated error messages', () => {
|
|
281
|
+
expect(isGhRateLimitError('Not Found (HTTP 404)')).toBe(false)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('returns false for an empty string', () => {
|
|
285
|
+
expect(isGhRateLimitError('')).toBe(false)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('returns false for a connection timeout', () => {
|
|
289
|
+
expect(isGhRateLimitError('connection timeout')).toBe(false)
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
describe('withGhRetry', () => {
|
|
294
|
+
it('returns the value when fn succeeds immediately', async () => {
|
|
295
|
+
const result = await withGhRetry('test label', async () => 'ok', false)
|
|
296
|
+
|
|
297
|
+
expect(result).toBe('ok')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('re-throws non-rate-limit errors without querying the reset time', async () => {
|
|
301
|
+
const queryReset = async (): Promise<string> => {
|
|
302
|
+
throw new Error('queryReset should not have been called')
|
|
303
|
+
}
|
|
304
|
+
const err = new Error('some other error')
|
|
305
|
+
|
|
306
|
+
await expect(withGhRetry('test label', async () => Promise.reject(err), false, queryReset)).rejects.toThrow(
|
|
307
|
+
'some other error',
|
|
308
|
+
)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('re-throws with reset time appended in non-interactive mode on rate limit', async () => {
|
|
312
|
+
const queryReset = async (): Promise<string> => '2:30 PM'
|
|
313
|
+
|
|
314
|
+
await expect(
|
|
315
|
+
withGhRetry(
|
|
316
|
+
'test label',
|
|
317
|
+
async () => {
|
|
318
|
+
throw new Error('API rate limit exceeded for url')
|
|
319
|
+
},
|
|
320
|
+
false,
|
|
321
|
+
queryReset,
|
|
322
|
+
),
|
|
323
|
+
).rejects.toThrow('resets at 2:30 PM')
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
269
327
|
describe('help output', () => {
|
|
270
328
|
it('shows --key, --repo, and --harness flags', () => {
|
|
271
329
|
const cli = goke('infra')
|
|
@@ -225,6 +225,75 @@ async function runGh(args: string[]): Promise<CommandResult> {
|
|
|
225
225
|
return runCommand('gh', args)
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
export function isGhRateLimitError(text: string): boolean {
|
|
229
|
+
return /rate limit/i.test(text)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Query the GitHub API rate limit reset time. The `rate_limit` endpoint is
|
|
234
|
+
* exempt from rate limiting itself, so this should succeed even when the
|
|
235
|
+
* primary GraphQL limit is exhausted. Returns a formatted local time string
|
|
236
|
+
* or a fallback phrase when the endpoint is unreachable.
|
|
237
|
+
*/
|
|
238
|
+
async function queryRateLimitReset(): Promise<string> {
|
|
239
|
+
try {
|
|
240
|
+
const result = await runGh(['api', 'rate_limit'])
|
|
241
|
+
if (result.exitCode === 0) {
|
|
242
|
+
const parsed = JSON.parse(result.stdout) as {
|
|
243
|
+
resources?: {graphql?: {reset?: number}; core?: {reset?: number}}
|
|
244
|
+
}
|
|
245
|
+
const reset = parsed.resources?.graphql?.reset ?? parsed.resources?.core?.reset
|
|
246
|
+
if (reset) {
|
|
247
|
+
return new Date(reset * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
// Fall through to generic phrase
|
|
252
|
+
}
|
|
253
|
+
return 'an unknown time'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Run a GitHub API operation wrapped in a spinner, retrying indefinitely on
|
|
258
|
+
* rate-limit errors when in interactive mode. In non-interactive mode the
|
|
259
|
+
* error is re-thrown with the reset time appended so the caller can surface
|
|
260
|
+
* it without prompting.
|
|
261
|
+
*/
|
|
262
|
+
export async function withGhRetry<T>(
|
|
263
|
+
label: string,
|
|
264
|
+
fn: (spinnerInstance: SpinnerResult) => Promise<T>,
|
|
265
|
+
interactive: boolean,
|
|
266
|
+
queryReset: () => Promise<string> = queryRateLimitReset,
|
|
267
|
+
): Promise<T> {
|
|
268
|
+
for (;;) {
|
|
269
|
+
try {
|
|
270
|
+
return await withSpinner(label, fn)
|
|
271
|
+
} catch (error) {
|
|
272
|
+
const message = extractErrorMessage(error)
|
|
273
|
+
if (!isGhRateLimitError(message)) {
|
|
274
|
+
throw error
|
|
275
|
+
}
|
|
276
|
+
const reset = await queryReset()
|
|
277
|
+
if (!interactive) {
|
|
278
|
+
throw new Error(`${message} — GitHub API rate limit resets at ${reset}. Re-run when ready.`)
|
|
279
|
+
}
|
|
280
|
+
log.warn(`GitHub API rate limit exceeded. Resets at ${reset}.`)
|
|
281
|
+
const retry = await promptValue(
|
|
282
|
+
confirm({
|
|
283
|
+
message: 'Retry this step when ready?',
|
|
284
|
+
active: 'retry',
|
|
285
|
+
inactive: 'abort',
|
|
286
|
+
initialValue: true,
|
|
287
|
+
}),
|
|
288
|
+
'Setup aborted after rate limit.',
|
|
289
|
+
)
|
|
290
|
+
if (!retry) {
|
|
291
|
+
cancelAndExit('Setup aborted after GitHub API rate limit.')
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
228
297
|
async function assertGhInstalled(): Promise<void> {
|
|
229
298
|
if (!Bun.which('gh')) {
|
|
230
299
|
throw new Error('GitHub CLI is required for cliproxy setup. Install gh first: https://cli.github.com/')
|
|
@@ -720,9 +789,13 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
|
|
|
720
789
|
resolveManagementKey()
|
|
721
790
|
}
|
|
722
791
|
|
|
723
|
-
await
|
|
724
|
-
|
|
725
|
-
|
|
792
|
+
await withGhRetry(
|
|
793
|
+
`Checking GitHub access for ${plan.repo}`,
|
|
794
|
+
async () => {
|
|
795
|
+
await assertRepoAccess(plan.repo)
|
|
796
|
+
},
|
|
797
|
+
interactive,
|
|
798
|
+
)
|
|
726
799
|
|
|
727
800
|
if (options.key) {
|
|
728
801
|
log.info('Using the provided API key value directly. No new CLIProxyAPI key will be created.')
|
|
@@ -756,10 +829,12 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
|
|
|
756
829
|
}
|
|
757
830
|
}
|
|
758
831
|
|
|
759
|
-
const [existingSecrets, existingVariables] = await
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
832
|
+
const [existingSecrets, existingVariables] = await withGhRetry(
|
|
833
|
+
'Checking existing GitHub secrets and variables',
|
|
834
|
+
async () =>
|
|
835
|
+
Promise.all([listExistingGhNames(plan.repo, 'secret'), listExistingGhNames(plan.repo, 'variable')]),
|
|
836
|
+
interactive,
|
|
837
|
+
)
|
|
763
838
|
const collisions = collectCollisions(plan.template, existingSecrets, existingVariables)
|
|
764
839
|
|
|
765
840
|
if (collisions.length > 0) {
|
|
@@ -796,26 +871,34 @@ export function registerCliproxySetup(cli: ReturnType<typeof goke>): void {
|
|
|
796
871
|
}
|
|
797
872
|
|
|
798
873
|
try {
|
|
799
|
-
await
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
874
|
+
await withGhRetry(
|
|
875
|
+
'Writing GitHub secrets and variables',
|
|
876
|
+
async spinnerInstance => {
|
|
877
|
+
for (const secret of plan.template.secrets) {
|
|
878
|
+
spinnerInstance.message(`Setting secret ${secret.name}`)
|
|
879
|
+
await applyGhValue('secret', secret.name, plan.repo, secret.value)
|
|
880
|
+
}
|
|
804
881
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
882
|
+
for (const variable of plan.template.variables) {
|
|
883
|
+
spinnerInstance.message(`Setting variable ${variable.name}`)
|
|
884
|
+
await applyGhValue('variable', variable.name, plan.repo, variable.value)
|
|
885
|
+
}
|
|
886
|
+
},
|
|
887
|
+
interactive,
|
|
888
|
+
)
|
|
810
889
|
|
|
811
890
|
await withSpinner('Verifying the new key through the proxy', async () => {
|
|
812
891
|
await assertProxyKeyWorks(baseUrl, plan.keyValue)
|
|
813
892
|
})
|
|
814
893
|
|
|
815
894
|
if (plan.harness === 'opencode') {
|
|
816
|
-
const workflow = await
|
|
817
|
-
|
|
818
|
-
|
|
895
|
+
const workflow = await withGhRetry(
|
|
896
|
+
`Checking ${plan.repo} fro-bot.yaml wiring`,
|
|
897
|
+
async () => {
|
|
898
|
+
return checkFroBotWorkflow(plan.repo)
|
|
899
|
+
},
|
|
900
|
+
interactive,
|
|
901
|
+
)
|
|
819
902
|
|
|
820
903
|
switch (workflow.kind) {
|
|
821
904
|
case 'missing': {
|
|
@@ -4,8 +4,8 @@ import {resolve} from 'node:path'
|
|
|
4
4
|
import {z} from 'zod'
|
|
5
5
|
|
|
6
6
|
const REPO = 'marcusrbrown/infra'
|
|
7
|
-
const WORKFLOW_NAME = 'Deploy'
|
|
8
|
-
const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy.yaml'
|
|
7
|
+
const WORKFLOW_NAME = 'Deploy KeeWeb'
|
|
8
|
+
const WORKFLOW_URL = 'https://github.com/marcusrbrown/infra/actions/workflows/deploy-keeweb.yaml'
|
|
9
9
|
|
|
10
10
|
const DEFAULT_HOST = 'box.heatvision.co'
|
|
11
11
|
const DEFAULT_REMOTE_USER = 'deploy-kw'
|
|
@@ -87,7 +87,7 @@ export function registerKeewebDeploy(cli: CliInstance): void {
|
|
|
87
87
|
cli
|
|
88
88
|
.command(
|
|
89
89
|
'keeweb deploy',
|
|
90
|
-
'Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.',
|
|
90
|
+
'Trigger KeeWeb deployment. Default mode dispatches the GitHub Deploy KeeWeb workflow. Use --local to run apps/keeweb/deploy.sh directly from this repo.',
|
|
91
91
|
)
|
|
92
92
|
.option(
|
|
93
93
|
'--local',
|
|
@@ -114,7 +114,7 @@ export function registerKeewebDeploy(cli: CliInstance): void {
|
|
|
114
114
|
'Print planned actions without validating preconditions, executing deploy.sh, or triggering GitHub Actions.',
|
|
115
115
|
),
|
|
116
116
|
)
|
|
117
|
-
.example('# Trigger GitHub Deploy workflow (default mode)')
|
|
117
|
+
.example('# Trigger GitHub Deploy KeeWeb workflow (default mode)')
|
|
118
118
|
.example('infra keeweb deploy')
|
|
119
119
|
.example('# Preview local deploy plan without side effects')
|
|
120
120
|
.example('infra keeweb deploy --local --dry-run')
|
|
@@ -170,7 +170,9 @@ export function registerKeewebDeploy(cli: CliInstance): void {
|
|
|
170
170
|
|
|
171
171
|
validateRemotePreconditions()
|
|
172
172
|
|
|
173
|
-
console.warn(
|
|
173
|
+
console.warn(
|
|
174
|
+
'Warning: the Deploy KeeWeb workflow includes nginx config deployment as part of workflow_dispatch logic.',
|
|
175
|
+
)
|
|
174
176
|
console.warn('Warning: the workflow requires keeweb environment approval before jobs execute.')
|
|
175
177
|
|
|
176
178
|
const child = Bun.spawn(['gh', 'workflow', 'run', WORKFLOW_NAME, '--repo', REPO], {
|
|
@@ -180,7 +182,7 @@ export function registerKeewebDeploy(cli: CliInstance): void {
|
|
|
180
182
|
|
|
181
183
|
const exitCode = await child.exited
|
|
182
184
|
if (exitCode !== 0) {
|
|
183
|
-
throw new Error(`Failed to trigger Deploy workflow (exit code ${exitCode})`)
|
|
185
|
+
throw new Error(`Failed to trigger Deploy KeeWeb workflow (exit code ${exitCode})`)
|
|
184
186
|
}
|
|
185
187
|
|
|
186
188
|
console.log(`Workflow triggered: ${WORKFLOW_URL}`)
|
|
@@ -116,7 +116,7 @@ export async function checkLastDeploy(verbose: boolean): Promise<CheckResult> {
|
|
|
116
116
|
'gh',
|
|
117
117
|
'run',
|
|
118
118
|
'list',
|
|
119
|
-
'--workflow=Deploy',
|
|
119
|
+
'--workflow=Deploy KeeWeb',
|
|
120
120
|
'--status=success',
|
|
121
121
|
'--limit=1',
|
|
122
122
|
'--json',
|
|
@@ -164,7 +164,7 @@ export async function checkLastDeploy(verbose: boolean): Promise<CheckResult> {
|
|
|
164
164
|
return {
|
|
165
165
|
title: 'Last successful deploy',
|
|
166
166
|
level: 'warning',
|
|
167
|
-
summary: 'No successful Deploy workflow runs found',
|
|
167
|
+
summary: 'No successful Deploy KeeWeb workflow runs found',
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import {relative, resolve} from 'node:path'
|
|
2
|
+
import {describe, expect, it} from 'bun:test'
|
|
3
|
+
import {parse as parseYaml} from 'yaml'
|
|
4
|
+
|
|
5
|
+
const REPO_ROOT = resolve(import.meta.dir, '../../..')
|
|
6
|
+
const INTERNAL_ORGS = new Set(['marcusrbrown'])
|
|
7
|
+
const ALLOWED_SHELL_SCRIPTS = new Set(['apps/keeweb/deploy.sh'])
|
|
8
|
+
|
|
9
|
+
// Accepted version-comment forms on SHA-pinned `uses:` lines:
|
|
10
|
+
// # v6.0.2 (semver tag)
|
|
11
|
+
// # renovate-changesets@0.2.31 (scoped release tag)
|
|
12
|
+
const VERSION_COMMENT_RE = /^#\s+(?:v\d+(?:\.\d+){0,2}|[\w@/-]+@\d+(?:\.\d+){0,2})\s*$/
|
|
13
|
+
|
|
14
|
+
// Match `[- ]uses: owner/repo@<sha>[ trailing]` — step-level (indented, possibly dash-prefixed) and job-level.
|
|
15
|
+
// Uses [ \t] instead of \s to avoid regex backtracking ambiguity between overlapping quantifiers.
|
|
16
|
+
const USES_SHA_LINE_RE = /^[ \t]+(?:-[ \t]+)?uses:[ \t]+(\S+)@([a-f0-9]{7,})(?:[ \t]+(\S.*))?$/
|
|
17
|
+
|
|
18
|
+
interface Violation {
|
|
19
|
+
file: string
|
|
20
|
+
detail: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// `.github/` is a dot-directory; Bun.Glob skips dot-dirs by default, so every
|
|
24
|
+
// glob that traverses `.github/` must pass `{ dot: true }`. Without it, the
|
|
25
|
+
// workflow rules silently pass on an empty file set. The tripwire test at the
|
|
26
|
+
// top of the suite asserts the file count is >= 1 to catch this regression.
|
|
27
|
+
function listWorkflowFiles(extension: '.yaml' | '.yml'): string[] {
|
|
28
|
+
const glob = new Bun.Glob(`.github/workflows/*${extension}`)
|
|
29
|
+
return [...glob.scanSync({cwd: REPO_ROOT, absolute: true, dot: true})]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function listPackageJsonFiles(): string[] {
|
|
33
|
+
// Bun.Glob auto-excludes node_modules/** by default; no package.json lives under dot-dirs.
|
|
34
|
+
const glob = new Bun.Glob('**/package.json')
|
|
35
|
+
return [...glob.scanSync({cwd: REPO_ROOT, absolute: true})].filter(f => !f.includes('/node_modules/'))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function listShellScriptFiles(): string[] {
|
|
39
|
+
const glob = new Bun.Glob('**/*.sh')
|
|
40
|
+
return [...glob.scanSync({cwd: REPO_ROOT, absolute: true})].filter(
|
|
41
|
+
f => !f.includes('/node_modules/') && !f.includes('/.cache/') && !f.includes('/dist/'),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Detect cross-org `secrets: inherit` on reusable-workflow job calls.
|
|
47
|
+
*
|
|
48
|
+
* Rules:
|
|
49
|
+
* - Jobs without a `uses:` string are step-based and out of scope.
|
|
50
|
+
* - `uses:` values starting with `./` or `../` are local reusable workflows; `secrets: inherit` is legitimate.
|
|
51
|
+
* - `uses:` values whose owner (first path segment) is in INTERNAL_ORGS are same-org; `secrets: inherit` is legitimate.
|
|
52
|
+
* - Everything else is cross-org and must not use `secrets: inherit`.
|
|
53
|
+
*/
|
|
54
|
+
export function findCrossOrgSecretsInherit(parsed: unknown): {jobId: string; uses: string}[] {
|
|
55
|
+
if (typeof parsed !== 'object' || parsed === null) return []
|
|
56
|
+
const jobs = (parsed as {jobs?: Record<string, unknown>}).jobs
|
|
57
|
+
if (typeof jobs !== 'object' || jobs === null) return []
|
|
58
|
+
|
|
59
|
+
const violations: {jobId: string; uses: string}[] = []
|
|
60
|
+
for (const [jobId, jobRaw] of Object.entries(jobs)) {
|
|
61
|
+
if (typeof jobRaw !== 'object' || jobRaw === null) continue
|
|
62
|
+
const job = jobRaw as {uses?: unknown; secrets?: unknown}
|
|
63
|
+
if (typeof job.uses !== 'string') continue
|
|
64
|
+
if (job.uses.startsWith('./') || job.uses.startsWith('../')) continue
|
|
65
|
+
const owner = job.uses.split('/')[0] ?? ''
|
|
66
|
+
if (INTERNAL_ORGS.has(owner)) continue
|
|
67
|
+
if (job.secrets === 'inherit') {
|
|
68
|
+
violations.push({jobId, uses: job.uses})
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return violations
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('repo conventions', () => {
|
|
75
|
+
it('tripwire: workflow glob resolves to at least one file (catches dot-dir glob regressions)', () => {
|
|
76
|
+
const workflows = listWorkflowFiles('.yaml')
|
|
77
|
+
expect(workflows.length).toBeGreaterThan(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('no `bundledDependencies` in any package.json', async () => {
|
|
81
|
+
const files = listPackageJsonFiles()
|
|
82
|
+
const offenders: string[] = []
|
|
83
|
+
for (const file of files) {
|
|
84
|
+
const json = (await Bun.file(file).json()) as Record<string, unknown>
|
|
85
|
+
if ('bundledDependencies' in json) {
|
|
86
|
+
offenders.push(relative(REPO_ROOT, file))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
expect(offenders).toEqual([])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("apps/keeweb/config/config.json has settings.dropboxSecret === ''", async () => {
|
|
93
|
+
const configPath = resolve(REPO_ROOT, 'apps/keeweb/config/config.json')
|
|
94
|
+
const config = (await Bun.file(configPath).json()) as {
|
|
95
|
+
settings?: {dropboxSecret?: unknown}
|
|
96
|
+
}
|
|
97
|
+
expect(config.settings?.dropboxSecret).toBe('')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('no `secrets: inherit` on any job whose `uses:` points to a cross-org workflow', async () => {
|
|
101
|
+
const files = listWorkflowFiles('.yaml')
|
|
102
|
+
const violations: Violation[] = []
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const text = await Bun.file(file).text()
|
|
105
|
+
const parsed = parseYaml(text, {merge: true})
|
|
106
|
+
for (const v of findCrossOrgSecretsInherit(parsed)) {
|
|
107
|
+
violations.push({
|
|
108
|
+
file: relative(REPO_ROOT, file),
|
|
109
|
+
detail: `job '${v.jobId}' uses '${v.uses}' with secrets: inherit`,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
expect(violations).toEqual([])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('no `ssh-keyscan` under .github/workflows/**', async () => {
|
|
117
|
+
const files = listWorkflowFiles('.yaml')
|
|
118
|
+
const violations: Violation[] = []
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
const text = await Bun.file(file).text()
|
|
121
|
+
const lines = text.split(/\r?\n/)
|
|
122
|
+
for (const [index, line] of lines.entries()) {
|
|
123
|
+
if (/\bssh-keyscan\b/.test(line)) {
|
|
124
|
+
violations.push({
|
|
125
|
+
file: relative(REPO_ROOT, file),
|
|
126
|
+
detail: `line ${index + 1}: ${line.trim()}`,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
expect(violations).toEqual([])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('every SHA-pinned `uses:` line has a trailing `# vX.Y.Z` or `# scope@X.Y.Z` comment', async () => {
|
|
135
|
+
const files = listWorkflowFiles('.yaml')
|
|
136
|
+
const violations: Violation[] = []
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
const text = await Bun.file(file).text()
|
|
139
|
+
const lines = text.split(/\r?\n/)
|
|
140
|
+
for (const [index, line] of lines.entries()) {
|
|
141
|
+
const match = line.match(USES_SHA_LINE_RE)
|
|
142
|
+
if (!match) continue
|
|
143
|
+
const [, ref = '', sha = '', tail] = match
|
|
144
|
+
const shortSha = sha.slice(0, 7)
|
|
145
|
+
if (tail === undefined) {
|
|
146
|
+
violations.push({
|
|
147
|
+
file: relative(REPO_ROOT, file),
|
|
148
|
+
detail: `line ${index + 1}: missing version comment on '${ref}@${shortSha}…'`,
|
|
149
|
+
})
|
|
150
|
+
} else if (!VERSION_COMMENT_RE.test(tail.trim())) {
|
|
151
|
+
violations.push({
|
|
152
|
+
file: relative(REPO_ROOT, file),
|
|
153
|
+
detail: `line ${index + 1}: malformed version comment '${tail.trim()}' on '${ref}@${shortSha}…'`,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
expect(violations).toEqual([])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('no `.yml` files under .github/workflows/ (use `.yaml`)', () => {
|
|
162
|
+
const files = listWorkflowFiles('.yml')
|
|
163
|
+
expect(files.map(f => relative(REPO_ROOT, f))).toEqual([])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('no `.sh` files outside `apps/keeweb/deploy.sh`', () => {
|
|
167
|
+
const files = listShellScriptFiles()
|
|
168
|
+
const offenders = files.map(f => relative(REPO_ROOT, f)).filter(f => !ALLOWED_SHELL_SCRIPTS.has(f))
|
|
169
|
+
expect(offenders).toEqual([])
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// (enforced) marker drift detection
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
//
|
|
177
|
+
// Every bullet in AGENTS.md tagged `(enforced)` must map to a known
|
|
178
|
+
// enforcement mechanism (test or ESLint rule). Adding `(enforced)` without
|
|
179
|
+
// backing enforcement, or deleting the enforcement while keeping the marker,
|
|
180
|
+
// both cause a test failure here.
|
|
181
|
+
//
|
|
182
|
+
// Manifest keys are unique substrings of the AGENTS.md bullet text.
|
|
183
|
+
// Values describe where the enforcement lives — they are documentation only.
|
|
184
|
+
const ENFORCED_MANIFEST: Record<string, string> = {
|
|
185
|
+
'Only bash script': 'conventions.test.ts: no .sh files outside apps/keeweb/deploy.sh',
|
|
186
|
+
'GitHub Actions': 'conventions.test.ts: .yaml extension + SHA-pin version comment',
|
|
187
|
+
'Cross-org reusable workflows': 'conventions.test.ts: no secrets: inherit on cross-org jobs',
|
|
188
|
+
'as any': 'eslint.config.ts: @typescript-eslint/no-explicit-any + ban-ts-comment at error',
|
|
189
|
+
'No secret values in tracked files': 'conventions.test.ts: settings.dropboxSecret === empty string',
|
|
190
|
+
'ssh-keyscan': 'conventions.test.ts: no ssh-keyscan under .github/workflows/**',
|
|
191
|
+
'Never `secrets: inherit`': 'conventions.test.ts: no secrets: inherit on cross-org jobs',
|
|
192
|
+
bundledDependencies: 'conventions.test.ts: no bundledDependencies in any package.json',
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
describe('(enforced) marker drift', () => {
|
|
196
|
+
it('every (enforced) bullet in AGENTS.md is accounted for in the enforcement manifest', async () => {
|
|
197
|
+
const agentsMd = await Bun.file(resolve(REPO_ROOT, 'AGENTS.md')).text()
|
|
198
|
+
const enforcedLines = agentsMd.split(/\r?\n/).filter((l: string) => /\(enforced\)/.test(l))
|
|
199
|
+
|
|
200
|
+
// Tripwire — if the grep logic breaks, the whole suite silently passes with 0 checks
|
|
201
|
+
expect(enforcedLines.length).toBeGreaterThan(0)
|
|
202
|
+
|
|
203
|
+
const unmatched = enforcedLines.filter(
|
|
204
|
+
(line: string) => !Object.keys(ENFORCED_MANIFEST).some((key: string) => line.includes(key)),
|
|
205
|
+
)
|
|
206
|
+
expect(unmatched, 'New (enforced) bullet has no manifest entry — add enforcement before tagging').toEqual([])
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('every manifest entry corresponds to an actual (enforced) bullet in AGENTS.md', async () => {
|
|
210
|
+
const agentsMd = await Bun.file(resolve(REPO_ROOT, 'AGENTS.md')).text()
|
|
211
|
+
const enforcedLines = agentsMd.split(/\r?\n/).filter((l: string) => /\(enforced\)/.test(l))
|
|
212
|
+
|
|
213
|
+
const stale = Object.keys(ENFORCED_MANIFEST).filter(
|
|
214
|
+
(key: string) => !enforcedLines.some((l: string) => l.includes(key)),
|
|
215
|
+
)
|
|
216
|
+
expect(stale, 'Manifest entry has no matching (enforced) bullet in AGENTS.md — remove or update').toEqual([])
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('@typescript-eslint/no-explicit-any is configured at error severity in eslint.config.ts', async () => {
|
|
220
|
+
const eslintConfig = await Bun.file(resolve(REPO_ROOT, 'eslint.config.ts')).text()
|
|
221
|
+
// Matches: '@typescript-eslint/no-explicit-any': 'error'
|
|
222
|
+
expect(eslintConfig).toMatch(/'@typescript-eslint\/no-explicit-any'\s*:\s*'error'/)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('@typescript-eslint/ban-ts-comment is configured in eslint.config.ts', async () => {
|
|
226
|
+
const eslintConfig = await Bun.file(resolve(REPO_ROOT, 'eslint.config.ts')).text()
|
|
227
|
+
expect(eslintConfig).toContain('@typescript-eslint/ban-ts-comment')
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Per-app invariants
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
//
|
|
235
|
+
// Guard that critical safety mechanisms in each app are not accidentally
|
|
236
|
+
// removed. These are the runtime equivalents of (enforced) markers: code that
|
|
237
|
+
// must remain present for the documented behaviour to hold.
|
|
238
|
+
|
|
239
|
+
describe('per-app invariants', () => {
|
|
240
|
+
it('cliproxy deploy.ts guards config.yaml upload with a remoteFileExists() check', async () => {
|
|
241
|
+
const deployTs = await Bun.file(resolve(REPO_ROOT, 'apps/cliproxy/src/deploy.ts')).text()
|
|
242
|
+
// The guard: remoteFileExists(host, `${REMOTE_DIR}/config/config.yaml`, env)
|
|
243
|
+
// This prevents overwriting runtime API keys on the server.
|
|
244
|
+
expect(deployTs).toContain('remoteFileExists')
|
|
245
|
+
expect(deployTs).toContain('config.yaml')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('keeweb build.ts defines an EXPECTED_SHA256 constant for archive integrity', async () => {
|
|
249
|
+
const buildTs = await Bun.file(resolve(REPO_ROOT, 'apps/keeweb/src/build.ts')).text()
|
|
250
|
+
// EXPECTED_SHA256 is the KeeWeb release zip checksum; its presence proves
|
|
251
|
+
// SHA verification is wired in and was not accidentally stripped.
|
|
252
|
+
expect(buildTs).toMatch(/const\s+EXPECTED_SHA256\s*=/)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
describe('findCrossOrgSecretsInherit', () => {
|
|
257
|
+
it('flags a cross-org reusable workflow that uses `secrets: inherit`', () => {
|
|
258
|
+
const parsed = parseYaml(`
|
|
259
|
+
jobs:
|
|
260
|
+
build:
|
|
261
|
+
uses: bfra-me/.github/.github/workflows/example.yaml@abc1234
|
|
262
|
+
secrets: inherit
|
|
263
|
+
`)
|
|
264
|
+
const violations = findCrossOrgSecretsInherit(parsed)
|
|
265
|
+
expect(violations).toHaveLength(1)
|
|
266
|
+
expect(violations[0]?.jobId).toBe('build')
|
|
267
|
+
expect(violations[0]?.uses).toContain('bfra-me/')
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('allows a same-org reusable workflow to use `secrets: inherit`', () => {
|
|
271
|
+
const parsed = parseYaml(`
|
|
272
|
+
jobs:
|
|
273
|
+
release:
|
|
274
|
+
uses: marcusrbrown/infra/.github/workflows/release.yaml@sha
|
|
275
|
+
secrets: inherit
|
|
276
|
+
`)
|
|
277
|
+
expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('allows a local (./) reusable workflow to use `secrets: inherit`', () => {
|
|
281
|
+
const parsed = parseYaml(`
|
|
282
|
+
jobs:
|
|
283
|
+
build:
|
|
284
|
+
uses: ./.github/workflows/local.yaml
|
|
285
|
+
secrets: inherit
|
|
286
|
+
`)
|
|
287
|
+
expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('ignores `secrets: inherit` strings appearing inside prose block scalars', () => {
|
|
291
|
+
// Mirrors fro-bot.yaml: SCHEDULE_PROMPT contains the literal string
|
|
292
|
+
// `secrets: inherit` inside a prompt heredoc, but no job-level key exists.
|
|
293
|
+
const parsed = parseYaml(`
|
|
294
|
+
env:
|
|
295
|
+
SCHEDULE_PROMPT: |
|
|
296
|
+
Remember: never use secrets: inherit with cross-org workflows.
|
|
297
|
+
jobs:
|
|
298
|
+
run:
|
|
299
|
+
runs-on: ubuntu-latest
|
|
300
|
+
steps:
|
|
301
|
+
- run: echo hi
|
|
302
|
+
`)
|
|
303
|
+
expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('does not flag jobs without a `uses:` key (step-based jobs)', () => {
|
|
307
|
+
const parsed = parseYaml(`
|
|
308
|
+
jobs:
|
|
309
|
+
lint:
|
|
310
|
+
runs-on: ubuntu-latest
|
|
311
|
+
steps:
|
|
312
|
+
- run: echo hi
|
|
313
|
+
`)
|
|
314
|
+
expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
|
|
315
|
+
})
|
|
316
|
+
})
|