Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
34 changes: 28 additions & 6 deletions .github/workflows/deploy-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ on:
description: Published release tag whose immutable source must be deployed.
required: true
type: string
release_commit:
description: Immutable commit resolved from the published release tag.
required: true
type: string
control_sha:
description: Trusted main commit that dispatched this deployment.
required: true
type: string
dispatch_nonce:
description: Unique release-workflow dispatch identity.
required: true
Expand Down Expand Up @@ -90,7 +98,7 @@ jobs:
run: pnpm deploy:demo

deploy:
if: ${{ github.event_name != 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }}
if: ${{ github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' }}
runs-on: ubuntu-24.04
timeout-minutes: 50
environment:
Expand Down Expand Up @@ -157,6 +165,8 @@ jobs:
env:
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ github.token }}
REQUESTED_CONTROL_SHA: ${{ inputs.control_sha }}
REQUESTED_RELEASE_COMMIT: ${{ inputs.release_commit }}
TRUSTED_SHA: ${{ steps.source.outputs.trusted_sha }}
REQUESTED_RELEASE_TAG: ${{ inputs.release_tag }}
TRUSTED_VERSION: ${{ steps.source.outputs.version }}
Expand All @@ -169,8 +179,16 @@ jobs:
echo "release_tag must be the current release ${expected_tag}" >&2
exit 1
Comment thread
wolfiesch marked this conversation as resolved.
Outdated
fi
if [[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]; then
echo "workflow_dispatch must run from the immutable release tag ${expected_tag}" >&2
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "workflow_dispatch must run from the trusted main branch" >&2
exit 1
fi
if [[ ! "$REQUESTED_CONTROL_SHA" =~ ^[0-9a-f]{40}$ || "$REQUESTED_CONTROL_SHA" != "$TRUSTED_SHA" ]]; then
echo "control_sha must match the trusted workflow source" >&2
exit 1
Comment thread
wolfiesch marked this conversation as resolved.
fi
if [[ ! "$REQUESTED_RELEASE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
echo "release_commit must be an immutable commit SHA" >&2
exit 1
fi
release_tag="$REQUESTED_RELEASE_TAG"
Expand All @@ -188,11 +206,12 @@ jobs:
exit 1
fi
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
if [[ "$source_sha" != "$TRUSTED_SHA" ]]; then
echo "release tag moved after this immutable deployment was dispatched" >&2
if [[ "$source_sha" != "$REQUESTED_RELEASE_COMMIT" ]]; then
echo "release tag no longer resolves to the requested immutable commit" >&2
exit 1
fi
elif ! git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"; then
fi
if ! git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"; then
echo "release tag source is not reachable from the trusted workflow source" >&2
exit 1
fi
Expand All @@ -203,10 +222,12 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ steps.immutable_source.outputs.source_sha }}
path: .release-source
persist-credentials: false

- name: Install dependencies
if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }}
working-directory: .release-source
run: pnpm install --frozen-lockfile

- name: Authenticate to AWS with GitHub OIDC
Expand All @@ -226,6 +247,7 @@ jobs:

- name: Build and deploy immutable release site
if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }}
working-directory: .release-source
env:
GH_TOKEN: ${{ github.token }}
T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }}
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -490,9 +490,11 @@ jobs:

- name: Dispatch and await the exact immutable production deployment
env:
CONTROL_SHA: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
SOURCE_SHA: ${{ needs.verify.outputs.source_sha }}
run: >-
node scripts/dispatch-site-deployment.mjs
--tag "$RELEASE_TAG"
--commit "$SOURCE_SHA"
--control-commit "$CONTROL_SHA"
63 changes: 50 additions & 13 deletions scripts/check-release-consistency.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ function requireAnyText(text, expectedValues, path, errors) {
}
}

function requireOneVariant(text, variants, path, errors) {
if (!variants.some((variant) => variant.every((expected) => text.includes(expected)))) {
errors.push(`${path} does not match a supported release-control variant`);
}
}

function extractWorkflowJob(source, jobName, errors) {
const lines = source.split(/\r?\n/u);
const header = ` ${jobName}:`;
Expand Down Expand Up @@ -1313,21 +1319,33 @@ export function collectReleaseConsistencyErrors(files, releaseTag) {
errors,
);
}
const siteDispatcher = files.get("scripts/dispatch-site-deployment.mjs") ?? "";
for (const expected of [
"dispatchAndWaitForSiteDeployment",
"body: { ref: tag, inputs: { release_tag: tag, dispatch_nonce: dispatchNonce } }",
"run.head_branch === tag",
"run.head_sha === commit",
"run.display_title === `Deploy project site ${tag} ${dispatchNonce}`",
'exact.conclusion !== "success"',
]) {
requireText(
files.get("scripts/dispatch-site-deployment.mjs") ?? "",
expected,
"scripts/dispatch-site-deployment.mjs",
errors,
);
requireText(siteDispatcher, expected, "scripts/dispatch-site-deployment.mjs", errors);
}
requireOneVariant(
siteDispatcher,
[
[
"body: { ref: tag, inputs: { release_tag: tag, dispatch_nonce: dispatchNonce } }",
"run.head_branch === tag",
"run.head_sha === commit",
],
[
"ref: CONTROL_BRANCH",
"release_commit: commit",
"control_sha: controlCommit",
"run.head_branch === CONTROL_BRANCH",
"run.head_sha === controlCommit",
],
],
"scripts/dispatch-site-deployment.mjs",
errors,
);
if (releaseWorkflow.includes("ref: ${{ env.RELEASE_TAG }}")) {
errors.push(
".github/workflows/release.yml must build from the verified immutable source SHA, not env.RELEASE_TAG",
Expand Down Expand Up @@ -1357,21 +1375,40 @@ export function collectReleaseConsistencyErrors(files, releaseTag) {
".github/workflows/deploy-site.yml",
errors,
);
const deploySiteWorkflow = files.get(".github/workflows/deploy-site.yml") ?? "";
for (const expected of [
"run-name: Deploy project site ${{ inputs.release_tag || github.ref_name }} ${{ inputs.dispatch_nonce || github.sha }}",
"startsWith(github.ref, 'refs/tags/')",
"dispatch_nonce:",
'[[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]',
'[[ "$source_sha" != "$TRUSTED_SHA" ]]',
'git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"',
]) {
requireText(
files.get(".github/workflows/deploy-site.yml") ?? "",
deploySiteWorkflow,
expected,
".github/workflows/deploy-site.yml",
errors,
);
}
requireOneVariant(
deploySiteWorkflow,
[
[
"startsWith(github.ref, 'refs/tags/')",
'[[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]',
'[[ "$source_sha" != "$TRUSTED_SHA" ]]',
],
[
"github.ref == 'refs/heads/main'",
"release_commit:",
"control_sha:",
'[[ "$GITHUB_REF" != "refs/heads/main" ]]',
'[[ "$source_sha" != "$REQUESTED_RELEASE_COMMIT" ]]',
"path: .release-source",
"working-directory: .release-source",
],
],
".github/workflows/deploy-site.yml",
errors,
);
requireText(
files.get(".github/workflows/deploy-site.yml") ?? "",
"ref: ${{ steps.immutable_source.outputs.source_sha }}",
Expand Down
15 changes: 10 additions & 5 deletions scripts/check-release-consistency.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ test("rejects updater channel, stable manifest, and publication-contract drift",
],
[
"scripts/dispatch-site-deployment.mjs",
(text) => text.replace("body: { ref: tag", 'body: { ref: "main"'),
(text) => text.replace("ref: CONTROL_BRANCH", 'ref: "untrusted"'),
],
[
"scripts/wait-for-exact-ci.mjs",
Expand All @@ -376,7 +376,7 @@ test("rejects updater channel, stable manifest, and publication-contract drift",
[
".github/workflows/deploy-site.yml",
(text) =>
text.replace("startsWith(github.ref, 'refs/tags/')", "github.ref == 'refs/heads/main'"),
text.replace("github.ref == 'refs/heads/main'", "github.ref == 'refs/tags/untrusted'"),
],
];
for (const [path, replace] of cases) {
Expand Down Expand Up @@ -793,6 +793,7 @@ test("deploys release site source only after artifact publication", () => {
assert.ok(releaseWorkflow.includes("node scripts/dispatch-site-deployment.mjs"));
assert.ok(releaseWorkflow.includes('--tag "$RELEASE_TAG"'));
assert.ok(releaseWorkflow.includes('--commit "$SOURCE_SHA"'));
assert.ok(releaseWorkflow.includes('--control-commit "$CONTROL_SHA"'));
assert.ok(!releaseWorkflow.includes("gh workflow run deploy-site.yml"));
const dispatchSite = releaseWorkflow.slice(releaseWorkflow.indexOf(" dispatch-site:"));
assert.ok(dispatchSite.includes("ref: ${{ github.sha }}"));
Expand All @@ -811,16 +812,20 @@ test("deploys release site source only after artifact publication", () => {

assert.ok(deployWorkflow.includes("workflow_dispatch:"));
assert.ok(deployWorkflow.includes("release_tag:"));
assert.ok(deployWorkflow.includes("release_commit:"));
assert.ok(deployWorkflow.includes("control_sha:"));
assert.ok(deployWorkflow.includes("dispatch_nonce:"));
assert.ok(deployWorkflow.includes("inputs.dispatch_nonce || github.sha"));
assert.ok(deployWorkflow.includes("startsWith(github.ref, 'refs/tags/')"));
assert.ok(deployWorkflow.includes('[[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]'));
assert.ok(deployWorkflow.includes("github.ref == 'refs/heads/main'"));
assert.ok(deployWorkflow.includes('[[ "$GITHUB_REF" != "refs/heads/main" ]]'));
assert.ok(deployWorkflow.includes('expected_tag="v${TRUSTED_VERSION}"'));
assert.ok(deployWorkflow.includes('release_tag="$expected_tag"'));
assert.ok(deployWorkflow.includes("releases/tags/${release_tag}"));
assert.ok(deployWorkflow.includes('[[ "$source_sha" != "$TRUSTED_SHA" ]]'));
assert.ok(deployWorkflow.includes('[[ "$source_sha" != "$REQUESTED_RELEASE_COMMIT" ]]'));
assert.ok(deployWorkflow.includes('git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"'));
assert.ok(deployWorkflow.includes("ref: ${{ steps.immutable_source.outputs.source_sha }}"));
assert.ok(deployWorkflow.includes("path: .release-source"));
assert.ok(deployWorkflow.includes("working-directory: .release-source"));
assert.ok(!deployWorkflow.includes('source_sha="$MAIN_SHA"'));
assert.ok(!deployWorkflow.includes("cache: pnpm"));
assert.ok(
Expand Down
40 changes: 29 additions & 11 deletions scripts/dispatch-site-deployment.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { readBoundedResponseBytes } from "./read-bounded-response.mjs";
const REPOSITORY = "LycaonLLC/t4-code";
const WORKFLOW = "deploy-site.yml";
const WORKFLOW_PATH = `.github/workflows/${WORKFLOW}`;
const CONTROL_BRANCH = "main";
const VERSION_TAG_PATTERN = /^v\d+\.\d+\.\d+$/u;
const COMMIT_PATTERN = /^[0-9a-f]{40}$/u;
const DISPATCH_NONCE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
Expand Down Expand Up @@ -59,7 +60,7 @@ async function apiJson(url, options) {
}
}

function exactRuns(payload, commit, tag, dispatchNonce) {
function exactRuns(payload, controlCommit, tag, dispatchNonce) {
if (!payload || typeof payload !== "object" || !Array.isArray(payload.workflow_runs)) {
throw new Error("GitHub workflow run list was malformed");
}
Expand All @@ -70,15 +71,16 @@ function exactRuns(payload, commit, tag, dispatchNonce) {
run.id > 0 &&
run.path === WORKFLOW_PATH &&
run.event === "workflow_dispatch" &&
run.head_sha === commit &&
run.head_branch === tag &&
run.head_sha === controlCommit &&
run.head_branch === CONTROL_BRANCH &&
run.display_title === `Deploy project site ${tag} ${dispatchNonce}`,
);
}

export async function dispatchAndWaitForSiteDeployment({
tag,
commit,
controlCommit,
token,
fetchImpl = fetch,
sleep = (milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)),
Expand All @@ -90,16 +92,19 @@ export async function dispatchAndWaitForSiteDeployment({
}) {
if (!VERSION_TAG_PATTERN.test(tag)) throw new Error("tag must be vX.Y.Z");
if (!COMMIT_PATTERN.test(commit)) throw new Error("commit must be a lowercase 40-character SHA");
if (!COMMIT_PATTERN.test(controlCommit)) {
throw new Error("controlCommit must be a lowercase 40-character SHA");
}
if (!DISPATCH_NONCE_PATTERN.test(dispatchNonce)) throw new Error("dispatchNonce must be a UUIDv4");
positiveInteger(pollIntervalMs, "pollIntervalMs");
positiveInteger(creationTimeoutMs, "creationTimeoutMs");
positiveInteger(completionTimeoutMs, "completionTimeoutMs");

const workflowUrl = `https://api.github.com/repos/${REPOSITORY}/actions/workflows/${WORKFLOW}`;
const runsUrl = `${workflowUrl}/runs?event=workflow_dispatch&head_sha=${commit}&per_page=100`;
const runsUrl = `${workflowUrl}/runs?event=workflow_dispatch&head_sha=${controlCommit}&per_page=100`;
const before = exactRuns(
await apiJson(runsUrl, { token, fetchImpl }),
commit,
controlCommit,
tag,
dispatchNonce,
);
Expand All @@ -109,7 +114,15 @@ export async function dispatchAndWaitForSiteDeployment({
token,
fetchImpl,
method: "POST",
body: { ref: tag, inputs: { release_tag: tag, dispatch_nonce: dispatchNonce } },
body: {
ref: CONTROL_BRANCH,
inputs: {
release_tag: tag,
release_commit: commit,
control_sha: controlCommit,
dispatch_nonce: dispatchNonce,
},
},
});
if (dispatch.status !== 204) {
throw new Error(`GitHub workflow dispatch returned HTTP ${dispatch.status}`);
Expand All @@ -120,21 +133,23 @@ export async function dispatchAndWaitForSiteDeployment({
while (now() <= creationDeadline) {
const runs = exactRuns(
await apiJson(runsUrl, { token, fetchImpl }),
commit,
controlCommit,
tag,
dispatchNonce,
);
run = runs.find(({ id }) => !existingIds.has(id));
if (run) break;
await sleep(pollIntervalMs);
}
if (!run) throw new Error(`GitHub did not create an exact ${WORKFLOW} run for ${commit}`);
if (!run) {
throw new Error(`GitHub did not create an exact ${WORKFLOW} run for ${controlCommit}`);
}

const completionDeadline = now() + completionTimeoutMs;
const runUrl = `https://api.github.com/repos/${REPOSITORY}/actions/runs/${run.id}`;
while (now() <= completionDeadline) {
const current = await apiJson(runUrl, { token, fetchImpl });
const exact = exactRuns({ workflow_runs: [current] }, commit, tag, dispatchNonce)[0];
const exact = exactRuns({ workflow_runs: [current] }, controlCommit, tag, dispatchNonce)[0];
if (!exact || exact.id !== run.id) throw new Error("GitHub site deployment run changed identity");
if (exact.status === "completed") {
if (exact.conclusion !== "success") {
Expand All @@ -155,10 +170,13 @@ function parseArguments(args) {
if (!value) throw new Error(`missing value for ${flag ?? "argument"}`);
if (flag === "--tag") options.tag = value;
else if (flag === "--commit") options.commit = value;
else if (flag === "--control-commit") options.controlCommit = value;
else throw new Error(`unknown argument ${flag}`);
}
if (!options.tag || !options.commit) {
throw new Error("usage: dispatch-site-deployment.mjs --tag vX.Y.Z --commit SHA");
if (!options.tag || !options.commit || !options.controlCommit) {
throw new Error(
"usage: dispatch-site-deployment.mjs --tag vX.Y.Z --commit SHA --control-commit SHA",
);
}
return options;
}
Expand Down
Loading
Loading