Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/scripts/gen-action-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
import { SECTIONS } from "../../src/sections/registry.js";
import { agree } from "../../src/text.js";
import { countWord } from "./lib/count-word.js";
import { escapeRe, type GeneratedRegion, regenerateRegions } from "./lib/generated-regions.js";
import { type GeneratedRegion, regenerateRegions } from "./lib/generated-regions.js";

const ROOT = join(import.meta.dir, "..", "..");

Expand Down Expand Up @@ -338,7 +338,7 @@ function blockShape(lines: string): RegExp {
}

function tableShape(header: string, cells: string): RegExp {
return blockShape(String.raw`${escapeRe(header)}\n(?:\| ${cells} \|\n)*`);
return blockShape(String.raw`${RegExp.escape(header)}\n(?:\| ${cells} \|\n)*`);
}

/** A JSON string literal as JSON.stringify() emits it: its own escapes only, bare quotes never. */
Expand Down Expand Up @@ -398,7 +398,7 @@ export const GENERATED_REGIONS: Readonly<Record<string, readonly GeneratedRegion
{
name: "policy-count-sentence",
placement: { kind: "under-heading", heading: "# The undeclared policy" },
body: blockShape(String.raw`[A-Z][a-z-]*${escapeRe(COUNT_SENTENCE_LEAD)}[^\n]+\.\n`),
body: blockShape(String.raw`[A-Z][a-z-]*${RegExp.escape(COUNT_SENTENCE_LEAD)}[^\n]+\.\n`),
render: block(() => renderPolicyCountSentence(knobbedSections())),
},
{
Expand All @@ -415,7 +415,7 @@ export const GENERATED_REGIONS: Readonly<Record<string, readonly GeneratedRegion
{
name: "permissions-grant-sentence",
placement: { kind: "under-heading", heading: "## What to grant" },
body: blockShape(String.raw`${escapeRe(GRANT_SENTENCE_LEAD)}[^\n]+\.\n`),
body: blockShape(String.raw`${RegExp.escape(GRANT_SENTENCE_LEAD)}[^\n]+\.\n`),
render: block(() => renderGrantSentence(SECTIONS)),
},
{
Expand All @@ -433,7 +433,7 @@ export const GENERATED_REGIONS: Readonly<Record<string, readonly GeneratedRegion
heading: "## Checking settings changes on pull requests",
},
body: blockShape(
String.raw`${escapeRe(NO_GATED_READS)}\n|${escapeRe(GATED_READS_LEAD_IN)}\n\n(?:${GATED_READ_BULLET})+`,
String.raw`${RegExp.escape(NO_GATED_READS)}\n|${RegExp.escape(GATED_READS_LEAD_IN)}\n\n(?:${GATED_READ_BULLET})+`,
),
render: block(() => renderCheckModeGatedReads(SECTIONS)),
},
Expand Down
25 changes: 10 additions & 15 deletions .github/scripts/gen-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,7 @@ import type { UndeclaredPolicy } from "../../src/types.js";
import { readArchitecture, renderArchitectureMermaid } from "./arch-lint.js";
import { COVERAGE_DATA, type CoverageData } from "./coverage-data.js";
import { ENDPOINT_ANCHORS, type EndpointAnchors } from "./endpoint-docs.js";
import {
escapeRe,
type GeneratedRegion,
regenerateRegions,
regionBounds,
} from "./lib/generated-regions.js";
import { type GeneratedRegion, regenerateRegions, regionBounds } from "./lib/generated-regions.js";

const ROOT = join(import.meta.dir, "..", "..");
export const COVERAGE_PATH = "docs/reference/coverage.md";
Expand Down Expand Up @@ -428,7 +423,7 @@ function sectionsTableRegion(name: string, heading: string): GeneratedRegion {
name,
placement: { kind: "under-heading", heading },
body: new RegExp(
String.raw`^\n(?:${escapeRe(TABLE_HEADER)}\n(?:\| \x60[a-z_]+\x60 \| [^\n]* \|\n)*)?$`,
String.raw`^\n(?:${RegExp.escape(TABLE_HEADER)}\n(?:\| \x60[a-z_]+\x60 \| [^\n]* \|\n)*)?$`,
),
render: () => `\n${renderSectionsTable(SECTIONS, DOCS)}\n`,
};
Expand All @@ -439,7 +434,7 @@ function outputsListRegion(name: string, heading: string): GeneratedRegion {
name,
placement: { kind: "under-heading", heading },
body: new RegExp(
String.raw`^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${escapeRe(RESULT_TAIL)})?$`,
String.raw`^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${RegExp.escape(RESULT_TAIL)})?$`,
),
render: () => renderOutputsList(RUN_RESULTS),
};
Expand All @@ -450,7 +445,7 @@ function patUrlRegion(name: string): GeneratedRegion {
return {
name,
placement: { kind: "tail" },
body: new RegExp(String.raw`^\n(?:\[${escapeRe(PAT_FORM_LABEL)}\]: \S+\n)?$`),
body: new RegExp(String.raw`^\n(?:\[${RegExp.escape(PAT_FORM_LABEL)}\]: \S+\n)?$`),
render: () => `\n[${PAT_FORM_LABEL}]: ${patFormUrl()}\n`,
};
}
Expand Down Expand Up @@ -505,10 +500,10 @@ const nonBlank = (excluded: string): string =>
String.raw`[ \t]*[^${excluded}\s][^${excluded}\r\n]*`;
const PROSE_LINE = `${nonBlank("")}\n`;
const CELL = nonBlank("|");
const KEY_CELL = String.raw`\[\x60[a-z_]+\x60\]\(${escapeRe(SECTIONS_PAGE)}\)(?: \(\x60${nonBlank("|\x60")}\x60\))?`;
const KEY_CELL = String.raw`\[\x60[a-z_]+\x60\]\(${RegExp.escape(SECTIONS_PAGE)}\)(?: \(\x60${nonBlank("|\x60")}\x60\))?`;
const SUPPORTED_ROWS = String.raw`(?:\| ${CELL} \| ${KEY_CELL} \| ${CELL} \|\n)+`;
const GAP_ROWS = String.raw`(?:\| ${CELL} \| ${CELL} \| ${CELL} \|\n)+`;
const GAPS_BODY = String.raw`(?:${PROSE_LINE}\n${escapeRe(GAPS_HEADER)}\n|${escapeRe(GAPS_HEADER)}\n${GAP_ROWS})`;
const GAPS_BODY = String.raw`(?:${PROSE_LINE}\n${RegExp.escape(GAPS_HEADER)}\n|${RegExp.escape(GAPS_HEADER)}\n${GAP_ROWS})`;
const BULLETS = `(?:- ${PROSE_LINE})+`;
const NOTE_GROUPS = String.raw`(?:\*\*${nonBlank("*")}\*\* \(\x60[a-z_]+\x60\)\n\n${BULLETS}\n)+`;

Expand All @@ -519,10 +514,10 @@ const COVERAGE_REGIONS: readonly GeneratedRegion[] = [
name: "coverage",
placement: { kind: "tail" },
body: new RegExp(
String.raw`^\n(?:(?:${PROSE_LINE}\n)+${escapeRe(SUPPORTED_HEADING)}\n\n${escapeRe(SUPPORTED_HEADER)}\n` +
String.raw`${SUPPORTED_ROWS}\n${escapeRe(NOTES_HEADING)}\n\n${NOTE_GROUPS}` +
String.raw`${escapeRe(GAPS_HEADING)}\n\n${GAPS_BODY}\n${escapeRe(NO_API_HEADING)}\n\n` +
String.raw`${PROSE_LINE}\n${BULLETS}\n${escapeRe(OUT_OF_SCOPE_HEADING)}\n\n${BULLETS})?$`,
String.raw`^\n(?:(?:${PROSE_LINE}\n)+${RegExp.escape(SUPPORTED_HEADING)}\n\n${RegExp.escape(SUPPORTED_HEADER)}\n` +
String.raw`${SUPPORTED_ROWS}\n${RegExp.escape(NOTES_HEADING)}\n\n${NOTE_GROUPS}` +
String.raw`${RegExp.escape(GAPS_HEADING)}\n\n${GAPS_BODY}\n${RegExp.escape(NO_API_HEADING)}\n\n` +
String.raw`${PROSE_LINE}\n${BULLETS}\n${RegExp.escape(OUT_OF_SCOPE_HEADING)}\n\n${BULLETS})?$`,
),
render: () => `\n${renderCoverage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS)}\n`,
},
Expand Down
5 changes: 0 additions & 5 deletions .github/scripts/lib/generated-regions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,6 @@ import { Parser } from "yaml";
/** Which comment syntax a file's markers use: a complete `<!-- -->` comment, or a whole-line YAML `#` comment. */
export type MarkerSyntax = "html" | "yaml";

/** `text` as a regex source matching itself literally; body shapes splice renderer constants through it. */
export function escapeRe(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/** Character offsets of one marker: `[start, end)`. */
export type MarkerSpan = readonly [start: number, end: number];

Expand Down
12 changes: 6 additions & 6 deletions src/discovery/central.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
*/

import { existsSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { extname, join, parse } from "node:path";
import { err, ok, type Result } from "neverthrow";
import { type SlugKey, slugKey } from "../github/slug.js";
import type { CentralFileProblem, ProblemOf } from "../problem.js";
import { type CentralTarget, SLUG_RE } from "./targets.js";

const YAML_EXT = /\.ya?ml$/;
const YAML_EXTENSIONS = new Set([".yml", ".yaml"]);

export function resolveCentralTargets(
reposDir: string,
Expand Down Expand Up @@ -52,13 +52,13 @@ export function resolveCentralTargets(
);
continue;
}
if (!YAML_EXT.test(inner)) {
if (!YAML_EXTENSIONS.has(extname(inner))) {
warnings.push(
`ignoring ${innerPath}: not a .yml/.yaml file, so it defines no target repository`,
);
continue;
}
addTarget(`${owner}/${inner.replace(YAML_EXT, "")}`, innerPath);
addTarget(`${owner}/${parse(inner).name}`, innerPath);
}
};

Expand All @@ -71,7 +71,7 @@ export function resolveCentralTargets(
scanOwnerDir(entryPath, entry);
continue;
}
if (!YAML_EXT.test(entry)) {
if (!YAML_EXTENSIONS.has(extname(entry))) {
warnings.push(
`ignoring ${entryPath}: not a .yml/.yaml file, so it defines no target repository`,
);
Expand All @@ -81,7 +81,7 @@ export function resolveCentralTargets(
ownerlessFiles.push(entryPath);
continue;
}
addTarget(`${adminOwner}/${entry.replace(YAML_EXT, "")}`, entryPath);
addTarget(`${adminOwner}/${parse(entry).name}`, entryPath);
}
if (ownerlessFiles.length > 0) {
errors.push({ kind: "ownerless", files: ownerlessFiles });
Expand Down
8 changes: 4 additions & 4 deletions src/engine/orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,12 +308,12 @@ export async function runForRepo(
const mask = new Set<string>();
for (const [key, names] of bySection) {
const resolution = resolveSecretRefs(names, env);
if (!resolution.ok) {
resolutionErrors.set(key, resolution.errors);
if (resolution.isErr()) {
resolutionErrors.set(key, resolution.error);
continue;
}
Object.assign(resolved, resolution.values);
for (const plaintext of resolution.mask) {
Object.assign(resolved, resolution.value.values);
for (const plaintext of resolution.value.mask) {
mask.add(plaintext);
}
}
Expand Down
59 changes: 26 additions & 33 deletions src/engine/secret-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@
* a reference in a target's file -> refused: a target must not route the operator's environment into itself
*/

import { err, ok, type Result } from "neverthrow";

/** Who authored the source: a `target` document (fetched from the target repository) has its references refused; flows/multi.ts decides. */
export type SettingsSource = "operator" | "target";

/** A syntactically valid reference: the env var name, without the `$`. */
interface SecretRef {
export interface SecretRef {
readonly name: string;
}

export type SecretRefCheck = { ok: true; ref: SecretRef } | { ok: false; error: string };

const REFERENCE_RE = /^\$[A-Z_][A-Z0-9_]*$/;

const EMBEDDED_REFERENCE_RE = /\$[A-Z_][A-Z0-9_]*/;
Expand All @@ -38,46 +38,39 @@ export function validateSecretRef(
value: string,
source: SettingsSource,
label: string,
): SecretRefCheck {
): Result<SecretRef, string> {
if (REFERENCE_RE.test(value)) {
const name = value.slice(1);
if (source === "target") {
return {
ok: false,
error: `${label} uses the secret reference ${value} in a target-fetched settings file; references are honored only in operator-owned settings sources, so a target repository cannot read the operator's environment`,
};
return err(
`${label} uses the secret reference ${value} in a target-fetched settings file; references are honored only in operator-owned settings sources, so a target repository cannot read the operator's environment`,
);
}
const reserved = RESERVED_REF_PREFIXES.find((prefix) => name.startsWith(prefix));
if (reserved !== undefined) {
return {
ok: false,
error: `${label} references the reserved runner variable ${value} (${reserved}* is refused): workflow inputs and GitHub/runner context cannot be routed into settings values`,
};
return err(
`${label} references the reserved runner variable ${value} (${reserved}* is refused): workflow inputs and GitHub/runner context cannot be routed into settings values`,
);
}
return { ok: true, ref: { name } };
return ok({ name });
}
const embedded = value.match(EMBEDDED_REFERENCE_RE)?.[0];
if (embedded !== undefined) {
return {
ok: false,
error: `${label} embeds ${embedded} without being a whole-value reference; it would otherwise ship verbatim as the secret. Make the entire value a single $NAME reference`,
};
return err(
`${label} embeds ${embedded} without being a whole-value reference; it would otherwise ship verbatim as the secret. Make the entire value a single $NAME reference`,
);
}
return {
ok: false,
error: `${label} carries a literal value, but settings files are committed plaintext - exactly what secret references exist to prevent. Set it to a whole-value $NAME reference and define NAME in the step's env block`,
};
return err(
`${label} carries a literal value, but settings files are committed plaintext - exactly what secret references exist to prevent. Set it to a whole-value $NAME reference and define NAME in the step's env block`,
);
}

export type SecretRefsResolution =
| {
ok: true;
/** Resolved plaintext, keyed by env var name (two fields may share one). */
values: Record<string, string>;
/** Every distinct plaintext value, for the caller to register with masking. */
mask: string[];
}
| { ok: false; errors: string[] };
export interface ResolvedSecretRefs {
/** Resolved plaintext, keyed by env var name (two fields may share one). */
values: Record<string, string>;
/** Every distinct plaintext value, for the caller to register with masking. */
mask: string[];
}

/** The variable a validated whole-value reference names: REFERENCE_RE admits `$NAME`, so the name follows the `$`. */
export function referenceName(reference: string): string {
Expand All @@ -94,7 +87,7 @@ export function referenceName(reference: string): string {
export function resolveSecretRefs(
names: readonly string[],
env: Record<string, string | undefined> = process.env,
): SecretRefsResolution {
): Result<ResolvedSecretRefs, string[]> {
const errors: string[] = [];
const resolved: Record<string, string> = {};
const mask = new Set<string>();
Expand All @@ -116,7 +109,7 @@ export function resolveSecretRefs(
mask.add(plaintext);
}
if (errors.length > 0) {
return { ok: false, errors };
return err(errors);
}
return { ok: true, values: resolved, mask: [...mask] };
return ok({ values: resolved, mask: [...mask] });
}
2 changes: 1 addition & 1 deletion src/engine/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface MintedSecretReference {
function mintSecretReference(variable: string, label: string): MintedSecretReference {
const reference = `$${variable}`;
const checked = validateSecretRef(reference, "operator", label);
if (!checked.ok) {
if (checked.isErr()) {
throw new Error(
`BUG: the snapshot minted a reference the settings file refuses: ${checked.error}`,
);
Expand Down
26 changes: 6 additions & 20 deletions src/engine/validate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** Shape validation against each section's loose zod shape; the parsed output, not the input, is what the engine applies. */

import { err, ok, type Result } from "neverthrow";
import { z } from "zod";
import { nonPlainKind } from "../plain-data.js";
import type { ProblemOf } from "../problem.js";
import { LIST_SECTIONS, type ListSection, SECTION_KEYS, type SettingsFile } from "../schema.js";
Expand All @@ -10,6 +11,7 @@ import {
type DeclaredSecretValue,
} from "../sections/contract/module.js";
import { listLayering, sectionModule, sectionShape } from "../sections/registry.js";
import { valueAt } from "../sections/shared/list-section.js";
import { agree, countNoun } from "../text.js";
import { type SettingsSource, validateSecretRef } from "./secret-refs.js";

Expand All @@ -19,18 +21,6 @@ function isListSection(key: string): key is ListSection {
return LIST_KEYS.has(key);
}

/** The value at an issue's path, so a null the author wrote can be told from a type the author got wrong. */
function valueAt(root: unknown, path: readonly PropertyKey[]): unknown {
let node: unknown = root;
for (const step of path) {
if (typeof node !== "object" || node === null) {
return undefined;
}
node = (node as Record<PropertyKey, unknown>)[step];
}
return node;
}

/** zod's issue fields this module reads; `legal` is this action's own, set where a shape refuses null itself. */
interface NullIssue {
code: string;
Expand Down Expand Up @@ -230,18 +220,14 @@ export function validateSectionShapes(
if (!parsed.success) {
const issues = parsed.error.issues;
for (const issue of issues.slice(0, 5)) {
const path = issue.path
.map((p) => (typeof p === "number" ? `[${p}]` : `.${String(p)}`))
.join("");
const path = z.core.toDotPath([key, ...issue.path]);
// A null the shape refused is the author saying "empty" where GitHub has no empty state: name the values that
// exist. A shape's own diagnostic (a custom issue) already names the fix, unless it supplies the legal values itself.
if (valueAt(declared, issue.path) === null && rewritesForNull(issue as NullIssue)) {
problems.push(
`${key}${path} has no empty state; write ${legalValues(issue as NullIssue)}`,
);
problems.push(`${path} has no empty state; write ${legalValues(issue as NullIssue)}`);
continue;
}
problems.push(`${key}${path}: ${issue.message}`);
problems.push(`${path}: ${issue.message}`);
}
if (issues.length > 5) {
// A silently truncated list costs one fix-and-rerun cycle per hidden offender.
Expand Down Expand Up @@ -295,7 +281,7 @@ function secretReferenceProblems(
const problems: string[] = [];
for (const { label, value } of module.secretValues?.(parsed) ?? []) {
const checked = validateSecretRef(value, source, label);
if (!checked.ok) {
if (checked.isErr()) {
problems.push(`${key}: ${checked.error}`);
}
}
Expand Down
Loading
Loading