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
5 changes: 3 additions & 2 deletions .github/standards/runner-policy/policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
"allowedInputs": ["runner", "prerequisite-result"],
"allowedSecrets": {}
},
"melodic-software/ci-workflows/.github/workflows/standards-sync.yml@99ac2f8c5b09dbb785d4eaf18465cbd96c30290c": {
"melodic-software/ci-workflows/.github/workflows/standards-sync.yml@35f2684ac953794b854bac1959df00e74eeca1d9": {
"routing": "hosted-only",
"allowedInputs": ["dry-run", "targets"],
"allowedSecrets": {
Expand Down Expand Up @@ -159,7 +159,8 @@
"governedReusableRunnerInput": {
"name": "runner",
"expression": "${{ inputs.runner }}",
"default": "ubuntu-24.04"
"default": "ubuntu-24.04",
"failureSentinel": "ci-runner-selection-failed"
},
"forbiddenHostedRunnerLabels": ["macos-latest", "ubuntu-latest", "windows-latest"],
"managedLabelPatterns": [
Expand Down
5 changes: 3 additions & 2 deletions .github/standards/runner-policy/policy.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@
"governedReusableRunnerInput": {
"type": "object",
"additionalProperties": false,
"required": ["name", "expression", "default"],
"required": ["name", "expression", "default", "failureSentinel"],
"properties": {
"name": { "$ref": "#/$defs/nonWhitespaceString" },
"expression": { "$ref": "#/$defs/nonWhitespaceString" },
"default": { "$ref": "#/$defs/nonWhitespaceString" }
"default": { "$ref": "#/$defs/nonWhitespaceString" },
"failureSentinel": { "const": "ci-runner-selection-failed" }
}
},
"forbiddenHostedRunnerLabels": { "$ref": "#/$defs/nonEmptyUniqueStringArray" },
Expand Down
199 changes: 182 additions & 17 deletions .github/standards/runner-policy/runner-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const validatePolicyStructure = SCHEMA_VALIDATOR.compile(POLICY_SCHEMA);
const validateRepositoryPolicyStructure = SCHEMA_VALIDATOR.compile(REPOSITORY_POLICY_SCHEMA);
const RUNNER_OUTPUT =
/^\s*\$\{\{\s*needs\.(?<selectorId>[A-Za-z0-9_-]+)\.outputs\.runner\s*\|\|\s*'(?<fallback>[^'\r\n]+)'\s*}}\s*$/;
const REQUIRED_RUNNER_OUTPUT =
/^\s*\$\{\{\s*needs\.(?<selectorId>[A-Za-z0-9_-]+)\.outputs\.runner\s*}}\s*$/;
const MATRIX_OUTPUT = /^\$\{\{ matrix\.([A-Za-z0-9_-]+) }}$/;
const FULL_SHA = /^[0-9a-f]{40}$/i;
const REUSABLE_WORKFLOW_PATH =
Expand Down Expand Up @@ -217,6 +219,19 @@ function validatePolicy(value) {
"policy.governedReusableRunnerInput.default must be an approved hosted runner label",
);
}
if (
approvedHostedRunnerLabels.has(value.governedReusableRunnerInput.failureSentinel) ||
forbiddenHostedRunnerLabels.has(
value.governedReusableRunnerInput.failureSentinel.toLowerCase(),
) ||
managedLabelRegexes.some((pattern) =>
pattern.test(value.governedReusableRunnerInput.failureSentinel),
)
) {
throw new ConfigurationError(
"policy.governedReusableRunnerInput.failureSentinel must remain outside every hosted and managed runner label set",
);
}
const hostedMatrixAxes = new Map();
for (const expression of value.hostedMatrixExpressions) {
const match = MATRIX_OUTPUT.exec(expression);
Expand Down Expand Up @@ -562,10 +577,18 @@ function localReusableWorkflowStatus(callerFile, job, policy, workflowIndex) {
isLocal: true,
approved: false,
reason:
"repository-local runner-input workflows must use workflow_call exclusively and declare the governed optional runner default",
"repository-local runner-input workflows must use workflow_call exclusively and declare either the governed optional runner default or a required runner with no default",
};
}
return { isLocal: true, approved: true, record, routing };
const runnerInput =
routing === "runner-input" ? governedReusableRunnerStatus(record.workflow, policy) : undefined;
return {
isLocal: true,
approved: true,
record,
routing,
...(runnerInput?.mode ? { runnerInputMode: runnerInput.mode } : {}),
};
}

function permissionCapability(workflow, job, inherited = "may-write") {
Expand Down Expand Up @@ -848,18 +871,140 @@ function reusableWorkflowStatus(job, policy) {
return { isReusable: true, approved: true, contract };
}

function routeStatus(jobId, target, job, jobs, policy, reusableContract) {
const match = RUNNER_OUTPUT.exec(target);
function normalizedConditionExpression(value) {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.replace(/\s+/g, " ").trim();
const wrapper = /^\$\{\{ (.*) }}$/.exec(normalized);
return wrapper?.[1];
}

function selfHostedSelectorConditionStatus(job, selectorId) {
const cancellation = cancellationSafeConditionStatus(job.if);
if (!cancellation.approved) {
return cancellation;
}
const expression = normalizedConditionExpression(job.if);
const suffix = [
`needs.${selectorId}.result == 'success'`,
`needs.${selectorId}.outputs.route == 'self-hosted'`,
`needs.${selectorId}.outputs.runner != ''`,
`needs.${selectorId}.outputs.runner == vars.CI_SELF_HOSTED_LABEL`,
].join(" && ");
if (expression !== `!cancelled() && ${suffix}` && !expression?.endsWith(` && ${suffix}`)) {
return {
approved: false,
reason:
"required local runner inputs must be guarded by selector success, the exact self-hosted route, a nonempty runner, and the governed self-hosted label",
};
}
return { approved: true };
}

function unroutableFailureStatus(jobId, target, job, jobs, policy) {
if (target !== policy.governedReusableRunnerInput.failureSentinel) {
return undefined;
}
const prerequisites = normalizeNeeds(job.needs);
if (prerequisites.length !== 1) {
return {
approved: false,
reason: `${jobId} must declare exactly one selector job in needs to use the unroutable failure sentinel`,
};
}
const [selectorId] = prerequisites;
const selector = jobs[selectorId];
const selectorResult = selectorStatus(selector, policy);
if (!selectorResult.isSelector || !selectorResult.approved) {
return {
approved: false,
reason: selectorResult.reason ?? `${selectorId} does not call an approved selector workflow`,
};
}
const expectedCondition =
`!cancelled() && (needs.${selectorId}.result != 'success' || ` +
`!(needs.${selectorId}.outputs.route == 'self-hosted' && ` +
`needs.${selectorId}.outputs.runner != '' && ` +
`needs.${selectorId}.outputs.runner == vars.CI_SELF_HOSTED_LABEL))`;
if (normalizedConditionExpression(job.if) !== expectedCondition) {
return {
approved: false,
reason:
"the unroutable failure sentinel requires the exact complement of a successful governed self-hosted selector route",
};
}
const allowedJobKeys = new Set([
"name",
"needs",
"if",
"runs-on",
"timeout-minutes",
"permissions",
"steps",
]);
const extraJobKeys = Object.keys(job).filter((key) => !allowedJobKeys.has(key));
if (extraJobKeys.length > 0) {
return {
approved: false,
reason: `the unroutable failure sentinel job has forbidden keys: ${extraJobKeys.join(", ")}`,
};
}
if (
job["timeout-minutes"] !== 1 ||
job.permissions === null ||
typeof job.permissions !== "object" ||
Array.isArray(job.permissions) ||
Object.keys(job.permissions).length !== 0
) {
return {
approved: false,
reason: "the unroutable failure sentinel job requires timeout-minutes: 1 and permissions: {}",
};
}
if (!Array.isArray(job.steps) || job.steps.length !== 1) {
return {
approved: false,
reason: "the unroutable failure sentinel job requires exactly one rejecting shell step",
};
}
const [step] = job.steps;
const stepKeys =
step !== null && typeof step === "object" && !Array.isArray(step) ? Object.keys(step) : [];
const lines = typeof step?.run === "string" ? step.run.trim().split(/\r?\n/) : [];
if (
stepKeys.some((key) => key !== "name" && key !== "run") ||
typeof step?.name !== "string" ||
step.name.trim() === "" ||
lines.length !== 2 ||
!/^echo "::error::[A-Za-z0-9][A-Za-z0-9 .:_-]*"$/.test(lines[0].trim()) ||
lines[1].trim() !== "exit 1"
) {
return {
approved: false,
reason:
"the unroutable failure sentinel step must only emit a static error annotation and exit 1",
};
}
return { approved: true, selectorId };
}

function routeStatus(jobId, target, job, jobs, policy, reusableContract, localRunnerInputMode) {
const fallbackMatch = RUNNER_OUTPUT.exec(target);
const requiredMatch = REQUIRED_RUNNER_OUTPUT.exec(target);
const configuredDefault = policy.governedReusableRunnerInput.default;
if (!match || match.groups.fallback !== configuredDefault) {
const usesOptionalDefault =
fallbackMatch !== null && fallbackMatch.groups.fallback === configuredDefault;
const usesRequiredInput = requiredMatch !== null && localRunnerInputMode === "required";
if (!usesOptionalDefault && !usesRequiredInput) {
return {
attempted: target.includes("outputs.runner"),
approved: false,
reason: `runner routing must use exactly needs.<selector-job>.outputs.runner || '${configuredDefault}'`,
reason: `runner routing must use exactly needs.<selector-job>.outputs.runner || '${configuredDefault}', or a raw selector output passed to a required no-default repository-local runner input`,
};
}

const selectorId = match.groups.selectorId;
const selectorId = (fallbackMatch ?? requiredMatch).groups.selectorId;
if (!normalizeNeeds(job.needs).includes(selectorId)) {
return {
attempted: true,
Expand All @@ -882,9 +1027,11 @@ function routeStatus(jobId, target, job, jobs, policy, reusableContract) {
if (!status.approved) {
return { attempted: true, approved: false, reason: status.reason };
}
const condition = reusableContract?.selectorResultInput
? failClosedSelectorConditionStatus(job, selectorId, reusableContract.selectorResultInput)
: cancellationSafeConditionStatus(job.if);
const condition = usesRequiredInput
? selfHostedSelectorConditionStatus(job, selectorId)
: reusableContract?.selectorResultInput
? failClosedSelectorConditionStatus(job, selectorId, reusableContract.selectorResultInput)
: cancellationSafeConditionStatus(job.if);
if (!condition.approved) {
return { attempted: true, approved: false, reason: condition.reason };
}
Expand Down Expand Up @@ -1022,17 +1169,21 @@ function governedReusableRunnerStatus(workflow, policy) {
reason: `${contract.expression} requires on.workflow_call.inputs.${contract.name}`,
};
}
if (
declaration.type !== "string" ||
declaration.default !== contract.default ||
declaration.required === true
) {
const optionalDefault =
declaration.type === "string" &&
declaration.default === contract.default &&
(declaration.required === undefined || declaration.required === false);
const requiredNoDefault =
declaration.type === "string" &&
declaration.required === true &&
!Object.hasOwn(declaration, "default");
if (!optionalDefault && !requiredNoDefault) {
return {
approved: false,
reason: `on.workflow_call.inputs.${contract.name} must be an optional string defaulting to ${contract.default}`,
reason: `on.workflow_call.inputs.${contract.name} must be either an optional string defaulting to ${contract.default} or a required string with no default`,
};
}
return { approved: true };
return { approved: true, mode: requiredNoDefault ? "required" : "optional-default" };
}

function hostedMatrixStatus(job, target, policy) {
Expand Down Expand Up @@ -1117,13 +1268,24 @@ function runnerTargetStatus(jobId, job, jobs, workflow, policy, file, workflowIn
};
}

const unroutableFailure = unroutableFailureStatus(jobId, target, job, jobs, policy);
if (unroutableFailure) {
return {
approved: unroutableFailure.approved,
kind: unroutableFailure.approved ? "unroutable-failure" : "invalid",
route: { attempted: true },
...(unroutableFailure.reason ? { reason: unroutableFailure.reason } : {}),
};
}

const route = routeStatus(
jobId,
target,
job,
jobs,
policy,
!local.approved && reusable.approved ? reusable.contract : undefined,
local.approved ? local.runnerInputMode : undefined,
);
if (route.approved) {
return { approved: true, kind: "selector-output", route };
Expand Down Expand Up @@ -1844,6 +2006,9 @@ export async function auditRepository({
continue;
}

if (target?.kind === "unroutable-failure" && target.approved) {
continue;
}
if (target?.kind === "selector-output" && target.approved) {
continue;
}
Expand Down
Loading