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
96 changes: 84 additions & 12 deletions .github/scripts/release-provenance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@

import { execFileSync } from "node:child_process";

const semverTagPattern =
/^v?(?<version>[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)$/u;
const numericIdentifier = "(?:0|[1-9][0-9]*)";
const prereleaseIdentifier = `(?:${numericIdentifier}|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)`;
const semverTagPattern = new RegExp(
`^v?(?<version>${numericIdentifier}\\.${numericIdentifier}\\.${numericIdentifier}` +
`(?:-${prereleaseIdentifier}(?:\\.${prereleaseIdentifier})*)?)$`,
"u",
);
const versionLikeTagPattern = /^v?[0-9]+\.[0-9]+\.[0-9]+(?:[-.].*)?$/u;
const dockerTagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/u;
const commitPattern = /^[0-9a-f]{40}$/u;

function fail(message) {
Expand All @@ -17,6 +24,9 @@ export function versionFromTag(tag) {
}

export function resolveReleaseSource(event, git) {
if (!commitPattern.test(event.eventSHA)) {
fail(`event SHA ${event.eventSHA} is not a full lowercase commit SHA`);
}
const eventCommit = git.commit(event.eventSHA);
if (event.releaseTarget !== event.defaultBranch) {
fail(`release target ${event.releaseTarget} is not ${event.defaultBranch}`);
Expand All @@ -36,24 +46,58 @@ export function resolveReleaseSource(event, git) {
`event commit ${eventCommit} is not an ancestor of ${event.defaultBranch}`,
);
}
return { imageTag: version, sourceSHA: eventCommit, immutableVersion: true };
const tagIdentity = git.tagIdentity(event.releaseTag);
return {
imageTag: version,
immutableVersion: true,
releaseRefOID: tagIdentity.oid,
releaseRefType: tagIdentity.type,
sourceSHA: eventCommit,
};
}

export function resolveWorkflowSource(event) {
export function resolveWorkflowSource(event, git) {
if (!commitPattern.test(event.eventSHA)) {
fail(`event SHA ${event.eventSHA} is not a full lowercase commit SHA`);
}
const imageTag = event.inputTag || "edge";
if (
event.eventName === "workflow_dispatch" &&
(imageTag === "edge" || !event.inputTag?.trim())
) {
fail("manual workflow dispatch may not publish edge");
const defaultRef = `refs/heads/${event.defaultBranch}`;
if (event.eventRef !== defaultRef) {
fail(`event ref ${event.eventRef} is not ${defaultRef}`);
}
if (git.branchCommit(event.defaultBranch) !== event.eventSHA) {
fail(
`event commit ${event.eventSHA} is not the tip of ${event.defaultBranch}`,
);
}
if (event.eventName === "push") {
if (event.inputTag) fail("main push unexpectedly supplied an image tag");
return {
imageTag: "edge",
immutableVersion: false,
releaseRefOID: "",
releaseRefType: "",
sourceSHA: event.eventSHA,
};
}
if (event.eventName !== "workflow_dispatch") {
fail(`unsupported workflow event ${event.eventName}`);
}
const imageTag = event.inputTag?.trim() ?? "";
if (imageTag !== event.inputTag || !dockerTagPattern.test(imageTag)) {
fail("manual workflow dispatch supplied an invalid Docker image tag");
}
if (imageTag === "edge" || imageTag === "latest") {
fail(`manual workflow dispatch may not publish ${imageTag}`);
}
if (versionLikeTagPattern.test(imageTag)) {
fail("manual workflow dispatch may not publish version tags");
}
return {
imageTag,
sourceSHA: event.eventSHA,
immutableVersion: versionFromTag(imageTag) !== null,
immutableVersion: false,
releaseRefOID: "",
releaseRefType: "",
};
}

Expand All @@ -77,6 +121,13 @@ function gitForRelease(defaultBranch, releaseTag) {
tagCommit(tag) {
return runGit(["rev-parse", "--verify", `${tag}^{commit}`]);
},
tagIdentity(tag) {
const oid = runGit(["rev-parse", "--verify", `refs/tags/${tag}`]);
return { oid, type: runGit(["cat-file", "-t", oid]) };
},
branchCommit(branch) {
return runGit(["rev-parse", "--verify", `origin/${branch}^{commit}`]);
},
isAncestor(commit, branch) {
try {
runGit(["merge-base", "--is-ancestor", commit, `origin/${branch}`]);
Expand All @@ -88,12 +139,29 @@ function gitForRelease(defaultBranch, releaseTag) {
};
}

function gitForBranch(defaultBranch) {
runGit([
"fetch",
"--force",
"--no-tags",
"origin",
`refs/heads/${defaultBranch}:refs/remotes/origin/${defaultBranch}`,
]);
return {
branchCommit(branch) {
return runGit(["rev-parse", "--verify", `origin/${branch}^{commit}`]);
},
};
}

function writeOutput(result) {
process.stdout.write(
[
`source_sha=${result.sourceSHA}`,
`image_tag=${result.imageTag}`,
`immutable_version=${result.immutableVersion}`,
`release_ref_oid=${result.releaseRefOID}`,
`release_ref_type=${result.releaseRefType}`,
].join("\n") + "\n",
);
}
Expand All @@ -102,6 +170,7 @@ if (process.argv[1] === new URL(import.meta.url).pathname) {
const event = {
defaultBranch: process.env.DEFAULT_BRANCH,
eventSHA: process.env.EVENT_SHA,
eventRef: process.env.EVENT_REF,
inputTag: process.env.INPUT_TAG,
releaseTag: process.env.RELEASE_TAG,
releaseTarget: process.env.RELEASE_TARGET,
Expand All @@ -118,7 +187,10 @@ if (process.argv[1] === new URL(import.meta.url).pathname) {
);
} else {
writeOutput(
resolveWorkflowSource({ ...event, eventName: process.env.EVENT_NAME }),
resolveWorkflowSource(
{ ...event, eventName: process.env.EVENT_NAME },
gitForBranch(event.defaultBranch),
),
);
}
}
79 changes: 73 additions & 6 deletions .github/scripts/release-registry-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ export function resolveImmutableTag({ expectedDigest, publishedDigest }) {
}

export function tagsToCopy({ tags, immutableTag, immutableState }) {
if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== "string" || !tag)) {
if (
!Array.isArray(tags) ||
tags.some((tag) => typeof tag !== "string" || !tag)
) {
fail("metadata action returned invalid image tags");
}
if (immutableState === "matching") {
Expand All @@ -39,10 +42,74 @@ export function tagsToCopy({ tags, immutableTag, immutableState }) {
fail(`unknown immutable image state ${immutableState}`);
}

export function assertPlatformIndex(index) {
if (!Array.isArray(index?.manifests)) {
fail("verified OCI archive is missing a manifest index");
}
const imageDescriptors = [];
const attestationSubjects = new Set();
for (const descriptor of index.manifests) {
if (!descriptor?.platform || !digestPattern.test(descriptor.digest)) {
fail("verified OCI archive contains an invalid manifest descriptor");
}
const { architecture, os } = descriptor.platform;
if (os === "unknown" || architecture === "unknown") {
const annotations = descriptor.annotations;
const subject = annotations?.["vnd.docker.reference.digest"];
if (
os !== "unknown" ||
architecture !== "unknown" ||
annotations?.["vnd.docker.reference.type"] !== "attestation-manifest" ||
!digestPattern.test(subject)
) {
fail("verified OCI archive contains an invalid attestation descriptor");
}
attestationSubjects.add(subject);
continue;
}
imageDescriptors.push(descriptor);
}
const platforms = imageDescriptors
.map(({ platform }) => `${platform.os}/${platform.architecture}`)
.sort();
if (
platforms.length !== 2 ||
platforms[0] !== "linux/amd64" ||
platforms[1] !== "linux/arm64"
) {
fail(
`verified OCI archive platforms are ${platforms.join(", ") || "empty"}, not linux/amd64 and linux/arm64`,
);
}
const imageDigests = new Set(imageDescriptors.map(({ digest }) => digest));
if (
imageDigests.size !== 2 ||
[...attestationSubjects].some((digest) => !imageDigests.has(digest)) ||
[...imageDigests].some((digest) => !attestationSubjects.has(digest))
) {
fail("verified OCI archive attestations do not match its image manifests");
}
}

if (process.argv[1] === new URL(import.meta.url).pathname) {
const state = resolveImmutableTag({
expectedDigest: process.env.EXPECTED_DIGEST,
publishedDigest: process.env.PUBLISHED_DIGEST,
});
process.stdout.write(`immutable_state=${state}\n`);
if (process.env.MODE === "validate-platforms") {
assertPlatformIndex(JSON.parse(process.env.MANIFEST_INDEX));
} else {
const state =
process.env.IMMUTABLE_VERSION === "true"
? resolveImmutableTag({
expectedDigest: process.env.EXPECTED_DIGEST,
publishedDigest: process.env.PUBLISHED_DIGEST,
})
: "";
const tags = tagsToCopy({
tags: process.env.TAGS.split("\n").filter(Boolean),
immutableTag: process.env.IMMUTABLE_TAG,
immutableState: state,
});
process.stdout.write(
`immutable_state=${state}\ntags_to_copy<<__RELEASE_TAGS__\n` +
`${tags.join("\n")}\n__RELEASE_TAGS__\n`,
);
}
}
Loading
Loading