@davidvornholt/standards 0.26.3 → 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.
@@ -1,5 +1,6 @@
1
1
  import { lstatSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { brokeredReferenceIdentity } from './dev-env-brokered-source';
3
4
  import type { DevEnvValue } from './dev-env-document';
4
5
  import { preserveRenderedDotenvValues, renderDotenv } from './dev-env-dotenv';
5
6
 
@@ -27,7 +28,7 @@ const preservedKeys = (
27
28
  ): ReadonlyArray<string> =>
28
29
  Object.entries(env).flatMap(([key, value]) =>
29
30
  typeof value !== 'string' &&
30
- references.has(`${value.brokeredS3}:${value.key}`)
31
+ references.has(brokeredReferenceIdentity(value))
31
32
  ? [key]
32
33
  : [],
33
34
  );
@@ -34,7 +34,7 @@ const layerReferenceTargets = (
34
34
  return {
35
35
  references: document.targets.flatMap((target) =>
36
36
  Object.values(target.env).flatMap((value) =>
37
- typeof value === 'string'
37
+ typeof value === 'string' || value.source !== undefined
38
38
  ? []
39
39
  : [{ target: value.brokeredS3, key: value.key }],
40
40
  ),
@@ -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) {
@@ -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> =>
@@ -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
+ };
@@ -0,0 +1,98 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { basename, extname, join } from 'node:path';
4
+ import { isContainedPath } from './contained-path';
5
+
6
+ const sourceExtensions = new Set(
7
+ '.ts,.tsx,.mts,.cts,.js,.jsx,.mjs,.cjs,.json,.jsonc,.yaml,.yml,.toml,.nix,.sh,.bash,.zsh,.rb,.py,.css,.scss,.html,.xml,.svg,.md,.mdx,.sql,.just'.split(
8
+ ',',
9
+ ),
10
+ );
11
+ const sourceFileNames = new Set([
12
+ 'Dockerfile',
13
+ 'Containerfile',
14
+ 'Makefile',
15
+ 'justfile',
16
+ '.envrc',
17
+ '.gitignore',
18
+ '.gitattributes',
19
+ '.dockerignore',
20
+ '.editorconfig',
21
+ '.npmrc',
22
+ ]);
23
+ const dotenvFileName = /^\.env(?:\..*)?$/u;
24
+ const forbiddenControl = /[^\P{Cc}\t\n]/u;
25
+ const gitOutputLimit = 16_777_216;
26
+ const hexadecimalRadix = 16;
27
+ const minimumCodePointWidth = 4;
28
+
29
+ export const inspectSourceText = (
30
+ path: string,
31
+ bytes: Uint8Array,
32
+ ): string | null => {
33
+ let text: string;
34
+ try {
35
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
36
+ } catch {
37
+ return `${JSON.stringify(path)}: source text must be valid UTF-8`;
38
+ }
39
+ const match = forbiddenControl.exec(text);
40
+ if (!match) {
41
+ return null;
42
+ }
43
+ const point = match[0]
44
+ .codePointAt(0)
45
+ ?.toString(hexadecimalRadix)
46
+ .toUpperCase()
47
+ .padStart(minimumCodePointWidth, '0');
48
+ const line = text.slice(0, match.index).split('\n').length;
49
+ return `${JSON.stringify(path)}:${line}: raw control character U+${point}; use an escaped representation (only tab and LF are allowed)`;
50
+ };
51
+
52
+ export const collectSourceTextProblems = (
53
+ root: string,
54
+ ): ReadonlyArray<string> => {
55
+ let tracked: string;
56
+ try {
57
+ tracked = execFileSync('git', ['ls-files', '--cached', '-z'], {
58
+ cwd: root,
59
+ encoding: 'utf8',
60
+ maxBuffer: gitOutputLimit,
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ });
63
+ } catch {
64
+ return [
65
+ 'Cannot enumerate tracked source files; run the gate from a readable Git working tree.',
66
+ ];
67
+ }
68
+ const problems: Array<string> = [];
69
+ const paths = new Set(
70
+ tracked
71
+ .split('\0')
72
+ .filter(
73
+ (path) =>
74
+ sourceExtensions.has(extname(path)) ||
75
+ sourceFileNames.has(basename(path)) ||
76
+ dotenvFileName.test(basename(path)),
77
+ ),
78
+ );
79
+ for (const path of paths) {
80
+ if (isContainedPath(root, path, 'file')) {
81
+ try {
82
+ const problem = inspectSourceText(path, readFileSync(join(root, path)));
83
+ if (problem !== null) {
84
+ problems.push(problem);
85
+ }
86
+ } catch {
87
+ problems.push(
88
+ `${JSON.stringify(path)}: cannot read tracked source text`,
89
+ );
90
+ }
91
+ } else {
92
+ problems.push(
93
+ `${JSON.stringify(path)}: tracked source must be a contained regular file`,
94
+ );
95
+ }
96
+ }
97
+ return problems;
98
+ };
@@ -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
  };
@@ -31,6 +31,7 @@ const SOURCE_ROOT_EXPECTATIONS: ReadonlyArray<RootScriptExpectation> = [
31
31
  name: 'check',
32
32
  commands: [
33
33
  `${SOURCE_CLI} structure --profile source`,
34
+ `${SOURCE_CLI} source-text`,
34
35
  `${SOURCE_CLI} dependabot --check`,
35
36
  `${SOURCE_CLI} github --check`,
36
37
  'turbo run lint check-types test --output-logs=errors-only',
@@ -41,6 +42,7 @@ const SOURCE_ROOT_EXPECTATIONS: ReadonlyArray<RootScriptExpectation> = [
41
42
  name: 'check:fix',
42
43
  commands: [
43
44
  `${SOURCE_CLI} structure --profile source`,
45
+ `${SOURCE_CLI} source-text`,
44
46
  `${SOURCE_CLI} dependabot --write`,
45
47
  `${SOURCE_CLI} github --check`,
46
48
  'turbo run lint:fix check-types test --output-logs=errors-only',