@davidvornholt/standards 0.26.4 → 0.28.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.
@@ -4,8 +4,9 @@
4
4
  // layer's reference, and a reference that lost the merge is never decrypted.
5
5
 
6
6
  import { lookupS3Pair } from './creds-r2';
7
- import { resolveTargetRelResult } from './creds-target';
8
7
  import type { BrokeredS3Reference } from './dev-env-brokered';
8
+ import { prepareBrokeredSources } from './dev-env-brokered-preflight';
9
+ import { brokeredReferenceIdentity } from './dev-env-brokered-source';
9
10
  import type { ComposedDevEnvTarget } from './dev-env-compose';
10
11
  import { encodePortableDotenvValue } from './dev-env-dotenv-value';
11
12
  import { decryptSopsJson, type SopsJsonResult } from './sops-exec';
@@ -55,39 +56,49 @@ export const resolveBrokeredReferences = (
55
56
  ): ResolvedDevEnv => {
56
57
  const problems: Array<string> = [];
57
58
  const documents = new Map<string, SopsJsonResult>();
58
- const readDocument = (targetName: string): SopsJsonResult => {
59
- const cached = documents.get(targetName);
59
+ const preflight = prepareBrokeredSources(
60
+ consumer,
61
+ targets,
62
+ allowedReferences,
63
+ preservedReferences,
64
+ );
65
+ if (preflight.problems.length > 0) {
66
+ return {
67
+ problems: preflight.problems,
68
+ targets: targets.map((target) => ({
69
+ ...target,
70
+ env: Object.fromEntries(
71
+ Object.entries(target.env).filter(
72
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
73
+ ),
74
+ ),
75
+ })),
76
+ };
77
+ }
78
+ const { sources } = preflight;
79
+ const readDocument = (reference: BrokeredS3Reference): SopsJsonResult => {
80
+ const source = sources.get(reference);
81
+ if (source === undefined) {
82
+ return { ok: false, problem: 'unresolved source target' };
83
+ }
84
+ const documentKey = `${source.root}/${source.rel}`;
85
+ const cached = documents.get(documentKey);
60
86
  if (cached !== undefined) {
61
87
  return cached;
62
88
  }
63
- const resolved = resolveTargetRelResult(consumer, targetName);
64
- const result = resolved.ok
65
- ? decryptSopsJson(consumer, resolved.rel)
66
- : {
67
- ok: false as const,
68
- problem:
69
- resolved.kind === 'ambiguous'
70
- ? resolved.problem
71
- : `secrets target "${targetName}" does not exist; create it and mint the pair with \`bun standards creds add cloudflare --s3\``,
72
- };
73
- documents.set(targetName, result);
89
+ const result = decryptSopsJson(source.root, source.rel);
90
+ documents.set(documentKey, result);
74
91
  return result;
75
92
  };
76
93
  const resolveValue = (
77
94
  label: string,
78
95
  reference: BrokeredS3Reference,
79
96
  ): { readonly value: string | null; readonly problem: string | null } => {
80
- const allowlistEntry = `${reference.brokeredS3}:${reference.key}`;
81
- if (!allowedReferences.has(allowlistEntry)) {
82
- return {
83
- value: null,
84
- problem: `${label}: unauthorized brokered S3 pair; add "${allowlistEntry}" to the encrypted secrets/dev.yaml brokeredReferences allowlist`,
85
- };
86
- }
97
+ const allowlistEntry = brokeredReferenceIdentity(reference);
87
98
  if (preservedReferences.has(allowlistEntry)) {
88
99
  return { value: '', problem: null };
89
100
  }
90
- const document = readDocument(reference.brokeredS3);
101
+ const document = readDocument(reference);
91
102
  if (!document.ok) {
92
103
  return { value: null, problem: `${label}: ${document.problem}` };
93
104
  }
@@ -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
+ };
@@ -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 part`,
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
- /github\.com[/:](?<repo>[^/]+\/[^/]+?)(?:\.git)?$/u;
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
- return url?.match(GITHUB_REMOTE_PATTERN)?.groups?.repo ?? null;
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 };
@@ -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
- const liveByName = new Map(live.rulesets.map((r) => [String(r.name), r]));
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) {
@@ -2,7 +2,7 @@
2
2
  // answers for tokens that cannot read the actors themselves. Pure logic; the
3
3
  // GraphQL call lives in github-graphql.ts.
4
4
 
5
- import { BYPASS_ACTORS_KEY } from './github-ruleset-diff';
5
+ import { BYPASS_ACTORS_KEY } from './github-settings-parse';
6
6
 
7
7
  // Stand-ins for bypass actors GraphQL counted but would not name. They exist
8
8
  // only to carry the count into the ordinary length comparison, and are attached
@@ -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 github-live-drift.ts
14
- // is the sole importer, so a stand-in cannot reach a PATCH body. The guarantee
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> =>
@@ -3,9 +3,11 @@
3
3
  // general value comparison lives in github-diff.ts.
4
4
 
5
5
  import { subsetMatches } from './github-diff';
6
- import { isRecord } from './github-settings-parse';
7
-
8
- export const BYPASS_ACTORS_KEY = 'bypass_actors';
6
+ import {
7
+ BYPASS_ACTORS_KEY,
8
+ isRecord,
9
+ rulesetComparedKeys,
10
+ } from './github-settings-parse';
9
11
 
10
12
  // A declared ruleset field the live side did not carry. It stays structured so
11
13
  // the caller owns both the wording and the test for *which* field it is;
@@ -21,13 +23,6 @@ export type RulesetDiff = {
21
23
  readonly unverifiable: ReadonlyArray<UnverifiableRulesetField>;
22
24
  };
23
25
 
24
- const RULESET_COMPARED_KEYS = [
25
- 'target',
26
- 'enforcement',
27
- 'conditions',
28
- BYPASS_ACTORS_KEY,
29
- ] as const;
30
-
31
26
  const diffRules = (
32
27
  name: string,
33
28
  declared: Readonly<Record<string, unknown>>,
@@ -99,15 +94,13 @@ export const diffRuleset = (
99
94
  const name = String(declared.name);
100
95
  const drifted: Array<string> = [];
101
96
  const unverifiable: Array<UnverifiableRulesetField> = [];
102
- for (const key of RULESET_COMPARED_KEYS) {
103
- if (declared[key] !== undefined) {
104
- if (live[key] === undefined) {
105
- unverifiable.push({ key, name });
106
- } else if (!subsetMatches(declared[key], live[key])) {
107
- drifted.push(
108
- `ruleset "${name}": ${key} differs from the declared configuration${bypassActorCountDetail(key, declared[key], live[key])}`,
109
- );
110
- }
97
+ for (const key of rulesetComparedKeys(declared.target)) {
98
+ if (live[key] === undefined) {
99
+ unverifiable.push({ key, name });
100
+ } else if (!subsetMatches(declared[key], live[key])) {
101
+ drifted.push(
102
+ `ruleset "${name}": ${key} differs from the declared configuration${bypassActorCountDetail(key, declared[key], live[key])}`,
103
+ );
111
104
  }
112
105
  }
113
106
  drifted.push(...diffRules(name, declared, live));
@@ -8,10 +8,8 @@ import {
8
8
  ADMIN_VISIBILITY_ADVICE,
9
9
  unverifiableProblem,
10
10
  } from './github-command-shared';
11
- import {
12
- BYPASS_ACTORS_KEY,
13
- type UnverifiableRulesetField,
14
- } from './github-ruleset-diff';
11
+ import type { UnverifiableRulesetField } from './github-ruleset-diff';
12
+ import { BYPASS_ACTORS_KEY } from './github-settings-parse';
15
13
 
16
14
  const SCOPE = 'ruleset field(s)';
17
15
 
@@ -45,6 +45,89 @@ export const isNamedRuleset = (
45
45
  ): value is Readonly<Record<string, unknown>> & { readonly name: string } =>
46
46
  isRecord(value) && typeof value.name === 'string' && value.name.length > 0;
47
47
 
48
+ export const BYPASS_ACTORS_KEY = 'bypass_actors';
49
+ const RULESET_COMPARED_KEYS = [
50
+ 'target',
51
+ 'enforcement',
52
+ 'conditions',
53
+ BYPASS_ACTORS_KEY,
54
+ ] as const;
55
+
56
+ // Push rules cover every push to the repository and have no ref-name scope.
57
+ // The parser and comparison share the same field contract.
58
+ export const rulesetComparedKeys = (target: unknown) =>
59
+ target === 'push'
60
+ ? RULESET_COMPARED_KEYS.filter((key) => key !== 'conditions')
61
+ : RULESET_COMPARED_KEYS;
62
+
63
+ const isStringList = (value: unknown): value is ReadonlyArray<string> =>
64
+ Array.isArray(value) && value.every((item) => typeof item === 'string');
65
+
66
+ const isOneOf = (value: unknown, choices: ReadonlyArray<string>): boolean =>
67
+ typeof value === 'string' && choices.includes(value);
68
+
69
+ const rulesetConditionsProblem = (
70
+ ruleset: Readonly<Record<string, unknown>>,
71
+ prefix: string,
72
+ ): string | null => {
73
+ if (ruleset.conditions === undefined) {
74
+ return null;
75
+ }
76
+ if (ruleset.target === 'push') {
77
+ return `${prefix} must omit "conditions" for target "push", which applies to every push`;
78
+ }
79
+ const refName = isRecord(ruleset.conditions)
80
+ ? ruleset.conditions.ref_name
81
+ : undefined;
82
+ return isRecord(refName) &&
83
+ isStringList(refName.include) &&
84
+ isStringList(refName.exclude)
85
+ ? null
86
+ : `${prefix} "conditions" must declare "ref_name.include" and "ref_name.exclude" as arrays of strings`;
87
+ };
88
+
89
+ const rulesetFieldProblems = (
90
+ ruleset: Readonly<Record<string, unknown>>,
91
+ label: string,
92
+ ): ReadonlyArray<string> => {
93
+ const problems: Array<string> = [];
94
+ const prefix = `${label} ruleset "${String(ruleset.name)}"`;
95
+ for (const key of rulesetComparedKeys(ruleset.target)) {
96
+ if (ruleset[key] === undefined) {
97
+ const advice =
98
+ key === BYPASS_ACTORS_KEY ? '; use [] if nobody may bypass' : '';
99
+ problems.push(`${prefix} must declare "${key}"${advice}`);
100
+ }
101
+ }
102
+ if (
103
+ ruleset.target !== undefined &&
104
+ !isOneOf(ruleset.target, ['branch', 'tag', 'push'])
105
+ ) {
106
+ problems.push(`${prefix} "target" must be "branch", "tag", or "push"`);
107
+ }
108
+ if (
109
+ ruleset.enforcement !== undefined &&
110
+ !isOneOf(ruleset.enforcement, ['active', 'disabled', 'evaluate'])
111
+ ) {
112
+ problems.push(
113
+ `${prefix} "enforcement" must be "active", "disabled", or "evaluate"`,
114
+ );
115
+ }
116
+ if (
117
+ ruleset[BYPASS_ACTORS_KEY] !== undefined &&
118
+ !Array.isArray(ruleset[BYPASS_ACTORS_KEY])
119
+ ) {
120
+ problems.push(
121
+ `${prefix} "${BYPASS_ACTORS_KEY}" must be an array; use [] if nobody may bypass`,
122
+ );
123
+ }
124
+ const conditionsProblem = rulesetConditionsProblem(ruleset, prefix);
125
+ if (conditionsProblem !== null) {
126
+ problems.push(conditionsProblem);
127
+ }
128
+ return problems;
129
+ };
130
+
48
131
  const LABEL_COLOR = /^[0-9a-f]{6}$/u;
49
132
  const LABEL_DECLARATION_KEY_COUNT = 3;
50
133
 
@@ -98,6 +181,9 @@ const rulesetListProblems = (
98
181
  } else {
99
182
  names.add(ruleset.name);
100
183
  }
184
+ if (isNamedRuleset(ruleset)) {
185
+ problems.push(...rulesetFieldProblems(ruleset, label));
186
+ }
101
187
  }
102
188
  return problems;
103
189
  };
@@ -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
+ };