The 16 sandboxed provider tests in `tests/unit/ci-artifact-contract.test.ts` fail wherever unprivileged user namespaces are denied. bubblewrap is installed and answers `--version`, but `bwrap --unshare-net … -- /bin/true` exits 1 with `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`, and it fails the same way with no netns flag at all (`setting up uid map: Permission denied`), so this is the whole nested-userns capability and not one option. Sixteen assertion errors on every local run buried whatever else the file had to say. `scripts/lib/provider-sandbox-probe.ts` now runs a trivial command under the real isolation options — `runProviderInSandbox` spreads the same `PROVIDER_SANDBOX_ISOLATION_ARGUMENTS`, and a test fails if an isolation option is added to the run without the probe having to clear it. Capability is measured, never inferred from the binary existing or from a version string; both would pass here. An unusable sandbox means two different things in two places, so the decision is explicit. Locally it is an environment fact: the affected tests skip and carry the bwrap diagnostic as their skip note, visible as `↓ … [reason]`. In CI it is a regression — a security gate that silently stopped running is exactly what these tests exist to catch — so the same probe result fails the run through one guard test that says "the provider sandbox is unavailable" instead of sixteen assertion errors. CI is detected with `CI === "true"` via a new `isCiRun`, sharing the predicate that already gates `ciBuildEnvironmentFailures`. The workflow sets it at the top-level `env:` block, so it holds in every job; `CI_RUN_ID` and its `GITEA_`/`GITHUB_` fallbacks are declared only by the release-tier provider jobs and are absent from the merge gates that run this file, so keying on them would have left the CI branch permanently dead. The three `it.each` groups become `it.for` because only `.for` passes the test context, which is what carries the skip note. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
109 lines
3.3 KiB
TypeScript
109 lines
3.3 KiB
TypeScript
export const CI_BUILD_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
"VITE_BUILD_ID",
|
|
"VITE_COMMIT_SHA",
|
|
"RELEASE_ID",
|
|
"CI_RUNNER_IMAGE",
|
|
"SOURCE_DATE_EPOCH",
|
|
]);
|
|
|
|
/**
|
|
* The repository's single definition of "this is a CI run". The workflow sets
|
|
* `CI: "true"` at the top-level `env:` block, so it holds in every job; the
|
|
* `CI_RUN_ID` family is declared only by the release-tier provider jobs and is
|
|
* absent from the merge gates.
|
|
*/
|
|
export function isCiRun(
|
|
environment: Readonly<Record<string, string | undefined>>,
|
|
) {
|
|
return environment.CI === "true";
|
|
}
|
|
|
|
export function ciBuildEnvironmentFailures(
|
|
environment: Readonly<Record<string, string | undefined>>,
|
|
) {
|
|
if (!isCiRun(environment)) return [];
|
|
|
|
const failures = CI_BUILD_ENVIRONMENT_VARIABLES.filter(
|
|
(name) => !environment[name]?.trim(),
|
|
).map((name) => `missing required CI build environment: ${name}`);
|
|
|
|
const commitSha = environment.VITE_COMMIT_SHA?.trim();
|
|
if (commitSha && !isValidCommitSha(commitSha)) {
|
|
failures.push(
|
|
"VITE_COMMIT_SHA must be a full 40- or 64-character hexadecimal commit ID",
|
|
);
|
|
}
|
|
|
|
const sourceDateEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
|
if (sourceDateEpoch && !isValidSourceDateEpoch(sourceDateEpoch)) {
|
|
failures.push("SOURCE_DATE_EPOCH must be non-negative epoch seconds");
|
|
}
|
|
|
|
const runnerImage = environment.CI_RUNNER_IMAGE?.trim();
|
|
if (
|
|
runnerImage &&
|
|
!/@sha256:[0-9a-f]{64}$/i.test(runnerImage)
|
|
) {
|
|
failures.push(
|
|
"CI_RUNNER_IMAGE must end with an immutable @sha256 image digest",
|
|
);
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
export function assertCiBuildEnvironment(
|
|
environment: Readonly<Record<string, string | undefined>>,
|
|
) {
|
|
const failures = ciBuildEnvironmentFailures(environment);
|
|
if (failures.length > 0) {
|
|
throw new Error(failures.join("; "));
|
|
}
|
|
}
|
|
|
|
export function isValidCommitSha(value: string) {
|
|
return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value);
|
|
}
|
|
|
|
export function isValidSourceDateEpoch(value: string) {
|
|
if (!/^\d+$/.test(value)) return false;
|
|
const milliseconds = Number(value) * 1_000;
|
|
return Number.isSafeInteger(milliseconds) && Number.isFinite(
|
|
new Date(milliseconds).getTime(),
|
|
);
|
|
}
|
|
|
|
export function ciCheckoutIdentityFailures(
|
|
environment: Readonly<Record<string, string | undefined>>,
|
|
checkout: { commitSha: string; sourceDateEpoch: string },
|
|
) {
|
|
if (environment.CI !== "true") return [];
|
|
|
|
const failures = [];
|
|
const configuredCommitSha = environment.VITE_COMMIT_SHA?.trim();
|
|
if (
|
|
configuredCommitSha &&
|
|
configuredCommitSha.toLowerCase() !== checkout.commitSha.toLowerCase()
|
|
) {
|
|
failures.push("VITE_COMMIT_SHA does not identify the checked-out commit");
|
|
}
|
|
const configuredEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
|
if (configuredEpoch && configuredEpoch !== checkout.sourceDateEpoch) {
|
|
failures.push(
|
|
"SOURCE_DATE_EPOCH does not match the checked-out commit timestamp",
|
|
);
|
|
}
|
|
return failures;
|
|
}
|
|
|
|
export function buildDate(
|
|
environment: Readonly<Record<string, string | undefined>>,
|
|
) {
|
|
const sourceDateEpoch = environment.SOURCE_DATE_EPOCH?.trim();
|
|
if (!sourceDateEpoch) return new Date();
|
|
if (!isValidSourceDateEpoch(sourceDateEpoch)) {
|
|
throw new Error("SOURCE_DATE_EPOCH must be non-negative epoch seconds");
|
|
}
|
|
return new Date(Number(sourceDateEpoch) * 1_000);
|
|
}
|