@davidvornholt/standards 0.26.4 → 0.27.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 +32 -1
- package/package.json +6 -2
- package/src/cli.ts +127 -16
- package/src/creds-add-github.ts +12 -4
- package/src/creds-add.ts +14 -1
- package/src/creds-commands.ts +7 -1
- package/src/creds-github-apps.ts +71 -2
- package/src/creds-login-cloudflare.ts +6 -6
- package/src/creds-login-github.ts +6 -2
- package/src/creds-naming.ts +4 -1
- package/src/creds-plan-renew.ts +14 -2
- package/src/creds-plan-report.ts +1 -1
- package/src/creds-plan-unmanaged.ts +4 -5
- package/src/creds-plan.ts +1 -1
- package/src/creds-revoke-duplicate.ts +127 -0
- package/src/creds-revoke.ts +36 -15
- package/src/creds-store-lock-inspection.ts +26 -21
- package/src/creds-store-lock.ts +14 -1
- package/src/dependabot-directory-overlap.ts +100 -8
- package/src/dependabot-update.ts +11 -0
- package/src/dev-env-brokered-allowlist.ts +2 -2
- package/src/dev-env-brokered-preflight.ts +69 -0
- package/src/dev-env-brokered-preserve.ts +2 -1
- package/src/dev-env-brokered-refresh.ts +1 -1
- package/src/dev-env-brokered-resolve.ts +33 -22
- package/src/dev-env-brokered-source.ts +128 -0
- package/src/dev-env-brokered.ts +15 -2
- package/src/dev-env.ts +22 -0
- package/src/github-api.ts +5 -2
- package/src/github-apply.ts +26 -2
- package/src/github-bypass-actors.ts +2 -2
- package/src/sops-action-callers.ts +133 -0
- package/src/structure-check.ts +5 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { parseDestination } from './creds-dest';
|
|
5
|
+
import { resolveTargetRelResult } from './creds-target';
|
|
6
|
+
import type { BrokeredS3Reference } from './dev-env-brokered';
|
|
7
|
+
import { isRecord } from './github-settings-parse';
|
|
8
|
+
|
|
9
|
+
const repositoryPattern =
|
|
10
|
+
/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
11
|
+
const gitSuffixPattern = /\.git$/u;
|
|
12
|
+
const sshRemotePattern = /^git@github\.com:(?<repository>[^\s]+?)(?:\.git)?$/u;
|
|
13
|
+
|
|
14
|
+
export type BrokeredS3Source = Readonly<{
|
|
15
|
+
repository: string;
|
|
16
|
+
checkout: string;
|
|
17
|
+
}>;
|
|
18
|
+
|
|
19
|
+
export const parseBrokeredSource = (raw: unknown): BrokeredS3Source | null => {
|
|
20
|
+
if (
|
|
21
|
+
!(
|
|
22
|
+
isRecord(raw) &&
|
|
23
|
+
Object.keys(raw).length === 2 &&
|
|
24
|
+
typeof raw.repository === 'string' &&
|
|
25
|
+
repositoryPattern.test(raw.repository) &&
|
|
26
|
+
typeof raw.checkout === 'string' &&
|
|
27
|
+
raw.checkout.trim() === raw.checkout &&
|
|
28
|
+
raw.checkout.length > 0 &&
|
|
29
|
+
!raw.checkout.includes('\\')
|
|
30
|
+
)
|
|
31
|
+
) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return { repository: raw.repository, checkout: raw.checkout };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const brokeredReferenceIdentity = (
|
|
38
|
+
reference: BrokeredS3Reference,
|
|
39
|
+
): string =>
|
|
40
|
+
`${reference.source === undefined ? '' : `${reference.source.repository}@`}${reference.brokeredS3}:${reference.key}`;
|
|
41
|
+
|
|
42
|
+
export const validBrokeredIdentity = (raw: string): boolean => {
|
|
43
|
+
const separator = raw.indexOf('@');
|
|
44
|
+
return separator === -1
|
|
45
|
+
? parseDestination(raw) !== null
|
|
46
|
+
: repositoryPattern.test(raw.slice(0, separator)) &&
|
|
47
|
+
parseDestination(raw.slice(separator + 1)) !== null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const remoteRepository = (remote: string): string | null => {
|
|
51
|
+
const ssh = sshRemotePattern.exec(remote)?.groups?.repository;
|
|
52
|
+
if (ssh !== undefined) {
|
|
53
|
+
return ssh;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(remote);
|
|
57
|
+
if (
|
|
58
|
+
url.hostname !== 'github.com' ||
|
|
59
|
+
!['https:', 'ssh:'].includes(url.protocol) ||
|
|
60
|
+
url.port !== '' ||
|
|
61
|
+
url.search !== '' ||
|
|
62
|
+
url.hash !== ''
|
|
63
|
+
) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return url.pathname.slice(1).replace(gitSuffixPattern, '');
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type SourceResolution =
|
|
73
|
+
| Readonly<{ ok: true; root: string; rel: string }>
|
|
74
|
+
| Readonly<{ ok: false; problem: string }>;
|
|
75
|
+
|
|
76
|
+
export const resolveBrokeredSource = (
|
|
77
|
+
consumer: string,
|
|
78
|
+
reference: BrokeredS3Reference,
|
|
79
|
+
): SourceResolution => {
|
|
80
|
+
let root = consumer;
|
|
81
|
+
if (reference.source !== undefined) {
|
|
82
|
+
try {
|
|
83
|
+
root = resolve(consumer, reference.source.checkout);
|
|
84
|
+
if (realpathSync(root) !== root) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
problem: 'source checkout must not contain symlinks',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const git = (args: ReadonlyArray<string>) =>
|
|
91
|
+
execFileSync('git', ['-C', root, ...args], {
|
|
92
|
+
encoding: 'utf8',
|
|
93
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
94
|
+
}).trim();
|
|
95
|
+
if (git(['rev-parse', '--show-toplevel']) !== root) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
problem: 'source checkout must name a repository root',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
remoteRepository(git(['remote', 'get-url', 'origin'])) !==
|
|
103
|
+
reference.source.repository
|
|
104
|
+
) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
problem:
|
|
108
|
+
'source checkout origin does not match the authorized repository',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
problem: 'source checkout is not an accessible Git repository',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const target = resolveTargetRelResult(root, reference.brokeredS3);
|
|
119
|
+
return target.ok
|
|
120
|
+
? { ok: true, root, rel: target.rel }
|
|
121
|
+
: {
|
|
122
|
+
ok: false,
|
|
123
|
+
problem:
|
|
124
|
+
target.kind === 'ambiguous'
|
|
125
|
+
? target.problem
|
|
126
|
+
: `secrets target "${reference.brokeredS3}" does not exist; create it and mint the pair with \`bun standards creds add cloudflare --s3\``,
|
|
127
|
+
};
|
|
128
|
+
};
|
package/src/dev-env-brokered.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import { parseDestination } from './creds-dest';
|
|
9
9
|
import { parseSopsKeyPath } from './creds-sops-structure';
|
|
10
|
+
import {
|
|
11
|
+
type BrokeredS3Source,
|
|
12
|
+
parseBrokeredSource,
|
|
13
|
+
} from './dev-env-brokered-source';
|
|
10
14
|
import { isRecord } from './github-settings-parse';
|
|
11
15
|
|
|
12
16
|
export const BROKERED_S3_PARTS = [
|
|
@@ -20,9 +24,10 @@ export type BrokeredS3Reference = {
|
|
|
20
24
|
readonly brokeredS3: string;
|
|
21
25
|
readonly key: string;
|
|
22
26
|
readonly part: BrokeredS3Part;
|
|
27
|
+
readonly source?: BrokeredS3Source;
|
|
23
28
|
};
|
|
24
29
|
|
|
25
|
-
const REFERENCE_PROPERTIES = ['brokeredS3', 'key', 'part'] as const;
|
|
30
|
+
const REFERENCE_PROPERTIES = ['brokeredS3', 'key', 'part', 'source'] as const;
|
|
26
31
|
|
|
27
32
|
export const isBrokeredS3ReferenceShape = (
|
|
28
33
|
value: unknown,
|
|
@@ -46,11 +51,18 @@ export const parseBrokeredS3Reference = (
|
|
|
46
51
|
for (const property of Object.keys(raw)) {
|
|
47
52
|
if (!REFERENCE_PROPERTIES.includes(property as 'brokeredS3')) {
|
|
48
53
|
problems.push(
|
|
49
|
-
`${label} brokered S3 pair reference has unknown property ${JSON.stringify(property)}; allowed properties are brokeredS3, key, and
|
|
54
|
+
`${label} brokered S3 pair reference has unknown property ${JSON.stringify(property)}; allowed properties are brokeredS3, key, part, and source`,
|
|
50
55
|
);
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
const { brokeredS3: target, key, part } = raw;
|
|
59
|
+
const source =
|
|
60
|
+
raw.source === undefined ? undefined : parseBrokeredSource(raw.source);
|
|
61
|
+
if (source === null) {
|
|
62
|
+
problems.push(
|
|
63
|
+
`${label} source must contain exactly a GitHub repository owner/name and a checkout path`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
54
66
|
if (
|
|
55
67
|
typeof target !== 'string' ||
|
|
56
68
|
parseDestination(`${target}:reference_validation`) === null
|
|
@@ -78,6 +90,7 @@ export const parseBrokeredS3Reference = (
|
|
|
78
90
|
brokeredS3: target as string,
|
|
79
91
|
key: key as string,
|
|
80
92
|
part: part as BrokeredS3Part,
|
|
93
|
+
...(source === undefined || source === null ? {} : { source }),
|
|
81
94
|
},
|
|
82
95
|
};
|
|
83
96
|
};
|
package/src/dev-env.ts
CHANGED
|
@@ -42,6 +42,21 @@ export type DevEnvPlan = {
|
|
|
42
42
|
readonly problems: ReadonlyArray<string>;
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
const workspaceConfigurationProblems = (
|
|
46
|
+
consumer: string,
|
|
47
|
+
targets: ReadonlyArray<import('./dev-env-compose').ComposedDevEnvTarget>,
|
|
48
|
+
): ReadonlyArray<string> =>
|
|
49
|
+
targets.flatMap((target) => {
|
|
50
|
+
const workspace = `${target.group}/${target.workspace}`;
|
|
51
|
+
if (!existsSync(join(consumer, workspace, 'package.json'))) {
|
|
52
|
+
return [
|
|
53
|
+
`${target.sources.join(' + ')} defines ${target.group}.${target.workspace}, but ${workspace}/package.json does not exist`,
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
const problem = devEnvGitIgnoreProblem(consumer, `${workspace}/.env.local`);
|
|
57
|
+
return problem === null ? [] : [problem];
|
|
58
|
+
});
|
|
59
|
+
|
|
45
60
|
export const planDevEnvChanges = (
|
|
46
61
|
consumer: string,
|
|
47
62
|
inputs: DevEnvInputs,
|
|
@@ -66,6 +81,13 @@ export const planDevEnvChanges = (
|
|
|
66
81
|
},
|
|
67
82
|
layer(DEV_LOCAL_FILE, inputs.local),
|
|
68
83
|
);
|
|
84
|
+
const configurationProblems = [
|
|
85
|
+
...composed.problems,
|
|
86
|
+
...workspaceConfigurationProblems(consumer, composed.targets),
|
|
87
|
+
];
|
|
88
|
+
if (configurationProblems.length > 0) {
|
|
89
|
+
return { writes: [], removals: [], problems: configurationProblems };
|
|
90
|
+
}
|
|
69
91
|
const resolved = resolveBrokeredReferences(
|
|
70
92
|
consumer,
|
|
71
93
|
composed.targets,
|
package/src/github-api.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { isRecord } from './github-settings-parse';
|
|
|
16
16
|
const API_ROOT = 'https://api.github.com';
|
|
17
17
|
|
|
18
18
|
const GITHUB_REMOTE_PATTERN =
|
|
19
|
-
|
|
19
|
+
/^(?:https?:\/\/(?:[^/@]+@)?github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)(?<repo>[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+?)(?:\.git)*$/iu;
|
|
20
20
|
|
|
21
21
|
export const HTTP_OK = 200;
|
|
22
22
|
export const HTTP_CREATED = 201;
|
|
@@ -55,7 +55,10 @@ export const resolveToken = (): string | null => {
|
|
|
55
55
|
|
|
56
56
|
export const resolveGithubRepo = (consumer: string): string | null => {
|
|
57
57
|
const url = quietExec('git', ['-C', consumer, 'remote', 'get-url', 'origin']);
|
|
58
|
-
|
|
58
|
+
const repo = url?.match(GITHUB_REMOTE_PATTERN)?.groups?.repo?.toLowerCase();
|
|
59
|
+
return repo === undefined || ['.', '..'].includes(repo.split('/')[1] ?? '')
|
|
60
|
+
? null
|
|
61
|
+
: repo;
|
|
59
62
|
};
|
|
60
63
|
|
|
61
64
|
export type ApiResponse = { readonly status: number; readonly body: unknown };
|
package/src/github-apply.ts
CHANGED
|
@@ -9,9 +9,12 @@ import {
|
|
|
9
9
|
HTTP_OK,
|
|
10
10
|
request,
|
|
11
11
|
} from './github-api';
|
|
12
|
+
import { resolveHiddenBypassActors } from './github-bypass-actors';
|
|
12
13
|
import { diffRepositorySettings } from './github-diff';
|
|
14
|
+
import { fetchBypassActorCountsViaGraphql } from './github-graphql';
|
|
13
15
|
import { fetchLiveRulesets } from './github-ruleset-api';
|
|
14
|
-
import { diffRuleset } from './github-ruleset-diff';
|
|
16
|
+
import { diffRuleset, diffRulesets } from './github-ruleset-diff';
|
|
17
|
+
import { isHiddenBypassActors } from './github-ruleset-visibility';
|
|
15
18
|
import { type GithubSettings, isRecord } from './github-settings-parse';
|
|
16
19
|
|
|
17
20
|
// GitHub answers a PATCH containing a plan-unavailable setting with HTTP 200
|
|
@@ -123,7 +126,28 @@ export const applyRulesets = async (
|
|
|
123
126
|
if (live.rulesets === null) {
|
|
124
127
|
throw new Error(live.problem ?? 'unable to read rulesets');
|
|
125
128
|
}
|
|
126
|
-
|
|
129
|
+
let comparable = live.rulesets;
|
|
130
|
+
const initialDiff = diffRulesets(declared.rulesets, comparable);
|
|
131
|
+
if (initialDiff.unverifiable.some(isHiddenBypassActors)) {
|
|
132
|
+
const answered = await fetchBypassActorCountsViaGraphql(token, repo);
|
|
133
|
+
comparable = resolveHiddenBypassActors(
|
|
134
|
+
declared.rulesets,
|
|
135
|
+
comparable,
|
|
136
|
+
answered.counts,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const invisible = diffRulesets(declared.rulesets, comparable).unverifiable;
|
|
140
|
+
if (invisible.length > 0) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`cannot verify ruleset fields before applying: ${invisible.map(({ name, key }) => `${name}: ${key}`).join('; ')}; use a credential that can read the declared fields`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const liveByName = new Map(comparable.map((r) => [String(r.name), r]));
|
|
146
|
+
if (liveByName.size !== comparable.length) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'duplicate live ruleset names prevent safe reconciliation; remove or rename the duplicate rulesets explicitly',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
127
151
|
const declaredNames = new Set(declared.rulesets.map((r) => String(r.name)));
|
|
128
152
|
const actions: Array<string> = [];
|
|
129
153
|
for (const ruleset of declared.rulesets) {
|
|
@@ -10,8 +10,8 @@ import { BYPASS_ACTORS_KEY } from './github-ruleset-diff';
|
|
|
10
10
|
// their content is never observed: subsetMatches short-circuits at the length
|
|
11
11
|
// check, two empty lists compare no element, and a declared ruleset with no
|
|
12
12
|
// bypass_actors array never reaches that comparison. Nothing else looks inside
|
|
13
|
-
// them; bypassActorCountDetail reads only `.length`, and
|
|
14
|
-
//
|
|
13
|
+
// them; bypassActorCountDetail reads only `.length`, and both check/apply only use these live records for comparison. Writes
|
|
14
|
+
// always send the original declaration, so a stand-in cannot reach a PATCH body. The guarantee
|
|
15
15
|
// is not local: it also relies on declared ruleset names being unique, which
|
|
16
16
|
// github-settings-parse.ts and github-settings-merge.ts enforce.
|
|
17
17
|
const unreadableBypassActors = (count: number): ReadonlyArray<unknown> =>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, posix } from 'node:path';
|
|
4
|
+
import { isRecord } from './github-settings-parse';
|
|
5
|
+
import type { ManagedEntry } from './managed-files';
|
|
6
|
+
import { parseYaml } from './yaml-parse';
|
|
7
|
+
|
|
8
|
+
const YAML_FILE = /\.ya?ml$/u;
|
|
9
|
+
const ACTION = '.github/actions/sops-secret/action.yml';
|
|
10
|
+
const LOCAL_ACTION = './.github/actions/sops-secret';
|
|
11
|
+
const yamlFiles = async (root: string, rel: string): Promise<Array<string>> => {
|
|
12
|
+
if (!existsSync(join(root, rel))) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const entries = await readdir(join(root, rel), { withFileTypes: true });
|
|
16
|
+
return (
|
|
17
|
+
await Promise.all(
|
|
18
|
+
entries.map((entry) => {
|
|
19
|
+
const path = `${rel}/${entry.name}`;
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
return yamlFiles(root, path);
|
|
22
|
+
}
|
|
23
|
+
return Promise.resolve(
|
|
24
|
+
entry.isFile() && YAML_FILE.test(path) ? [path] : [],
|
|
25
|
+
);
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
).flat();
|
|
29
|
+
};
|
|
30
|
+
const hasLegacyCaller = (value: unknown): boolean => {
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
return value.some(hasLegacyCaller);
|
|
33
|
+
}
|
|
34
|
+
if (!isRecord(value)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
(typeof value.uses === 'string' &&
|
|
39
|
+
value.uses.startsWith('./') &&
|
|
40
|
+
posix.resolve('/', value.uses) === posix.resolve('/', LOCAL_ACTION) &&
|
|
41
|
+
isRecord(value.with) &&
|
|
42
|
+
Object.hasOwn(value.with, 'env-name')) ||
|
|
43
|
+
Object.values(value).some(hasLegacyCaller)
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const localActions = (value: unknown): Array<string> => {
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
return value.flatMap(localActions);
|
|
50
|
+
}
|
|
51
|
+
if (!isRecord(value)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const reference = value.uses;
|
|
55
|
+
return [
|
|
56
|
+
...(typeof reference === 'string' && reference.startsWith('./')
|
|
57
|
+
? [posix.normalize(reference)]
|
|
58
|
+
: []),
|
|
59
|
+
...Object.values(value).flatMap(localActions),
|
|
60
|
+
];
|
|
61
|
+
};
|
|
62
|
+
const callerProblems = async (
|
|
63
|
+
consumer: string,
|
|
64
|
+
incoming: ReadonlyMap<string, ManagedEntry>,
|
|
65
|
+
): Promise<Array<string>> => {
|
|
66
|
+
const visited = new Set<string>();
|
|
67
|
+
const visit = async (path: string): Promise<Array<string>> => {
|
|
68
|
+
if (visited.has(path)) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
visited.add(path);
|
|
72
|
+
const source = incoming.get(path)?.absolutePath ?? join(consumer, path);
|
|
73
|
+
const parsed = parseYaml(await readFile(source, 'utf8'), path);
|
|
74
|
+
if (parsed.problem !== null) {
|
|
75
|
+
return [parsed.problem];
|
|
76
|
+
}
|
|
77
|
+
const problems =
|
|
78
|
+
!incoming.has(path) && hasLegacyCaller(parsed.value)
|
|
79
|
+
? [
|
|
80
|
+
`${path} still passes env-name to ${LOCAL_ACTION}; before syncing, give the resolver step an id, remove env-name, and pass steps.<id>.outputs.value only to each consuming step's env or action input`,
|
|
81
|
+
]
|
|
82
|
+
: [];
|
|
83
|
+
const nested = await Promise.all(
|
|
84
|
+
localActions(parsed.value).map(async (directory) => {
|
|
85
|
+
if (directory === '..' || directory.startsWith('../')) {
|
|
86
|
+
return [`${path} references a local action outside the repository`];
|
|
87
|
+
}
|
|
88
|
+
const paths = ['action.yml', 'action.yaml']
|
|
89
|
+
.map((filename) => posix.join(directory, filename))
|
|
90
|
+
.filter(
|
|
91
|
+
(target) =>
|
|
92
|
+
incoming.has(target) || existsSync(join(consumer, target)),
|
|
93
|
+
);
|
|
94
|
+
return (await Promise.all(paths.map(visit))).flat();
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
return [...problems, ...nested.flat()];
|
|
98
|
+
};
|
|
99
|
+
return (
|
|
100
|
+
await Promise.all((await yamlFiles(consumer, '.github')).map(visit))
|
|
101
|
+
).flat();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Called before mirror writes. Consumer-owned workflows and composite actions
|
|
105
|
+
// are not silently rewritten, so an action interface migration must stop before
|
|
106
|
+
// replacing the implementation they still invoke with its retired input.
|
|
107
|
+
export const collectSopsActionCallerProblems = async (
|
|
108
|
+
consumer: string,
|
|
109
|
+
incoming: ReadonlyMap<string, ManagedEntry> = new Map(),
|
|
110
|
+
): Promise<ReadonlyArray<string>> => {
|
|
111
|
+
const action = incoming.get(ACTION);
|
|
112
|
+
const actionPath = action?.absolutePath ?? join(consumer, ACTION);
|
|
113
|
+
if (
|
|
114
|
+
!existsSync(actionPath) ||
|
|
115
|
+
(action !== undefined && action.kind !== 'file')
|
|
116
|
+
) {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
const { value: definition } = parseYaml(
|
|
120
|
+
await readFile(actionPath, 'utf8'),
|
|
121
|
+
ACTION,
|
|
122
|
+
);
|
|
123
|
+
if (
|
|
124
|
+
!(
|
|
125
|
+
isRecord(definition) &&
|
|
126
|
+
isRecord(definition.outputs) &&
|
|
127
|
+
Object.hasOwn(definition.outputs, 'value')
|
|
128
|
+
)
|
|
129
|
+
) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
return callerProblems(consumer, incoming);
|
|
133
|
+
};
|
package/src/structure-check.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isAbsolute, join } from 'node:path';
|
|
|
4
4
|
import { isContainedPath } from './contained-path';
|
|
5
5
|
import { isRecord } from './github-settings-parse';
|
|
6
6
|
import { readJsonFile } from './json-file';
|
|
7
|
+
import { collectSopsActionCallerProblems } from './sops-action-callers';
|
|
7
8
|
import { collectBunVersionProblems } from './structure-bun-version';
|
|
8
9
|
import {
|
|
9
10
|
missingPublishedCliProblems,
|
|
@@ -154,14 +155,16 @@ export const collectStructureProblems = async (
|
|
|
154
155
|
consumer: string,
|
|
155
156
|
profile: StructureProfile,
|
|
156
157
|
): Promise<ReadonlyArray<string>> => {
|
|
157
|
-
const [root, secretsProblems] = await Promise.all([
|
|
158
|
+
const [root, secretsProblems, callerProblems] = await Promise.all([
|
|
158
159
|
readJsonFile(join(consumer, 'package.json')),
|
|
159
160
|
collectCiSecretsProblems(consumer),
|
|
161
|
+
collectSopsActionCallerProblems(consumer),
|
|
160
162
|
]);
|
|
161
163
|
if (root === null) {
|
|
162
164
|
return [
|
|
163
165
|
'package.json must exist and contain a JSON object',
|
|
164
166
|
...secretsProblems,
|
|
167
|
+
...callerProblems,
|
|
165
168
|
];
|
|
166
169
|
}
|
|
167
170
|
const declaration = workspacePatternsOf(root);
|
|
@@ -193,5 +196,6 @@ export const collectStructureProblems = async (
|
|
|
193
196
|
...inspectRootScripts(root, profile, requireA11y),
|
|
194
197
|
...inspections.flatMap((i) => [...i.problems]),
|
|
195
198
|
...secretsProblems,
|
|
199
|
+
...callerProblems,
|
|
196
200
|
];
|
|
197
201
|
};
|