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
72 changes: 55 additions & 17 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 All @@ -114,6 +122,27 @@ jobs:
version=$(node -p "require('./package.json').version")
printf 'version=%s\ntrusted_sha=%s\n' "$version" "$TRUSTED_SHA" >> "$GITHUB_OUTPUT"

- name: Resolve requested release identity
id: release_identity
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
REQUESTED_RELEASE_TAG: ${{ inputs.release_tag }}
TRUSTED_VERSION: ${{ steps.source.outputs.version }}
run: |
set -euo pipefail
release_tag="v${TRUSTED_VERSION}"
release_version="$TRUSTED_VERSION"
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
if [[ ! "$REQUESTED_RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "release_tag must be vX.Y.Z" >&2
exit 1
fi
release_tag="$REQUESTED_RELEASE_TAG"
release_version="${release_tag#v}"
fi
printf 'release_tag=%s\nrelease_version=%s\n' "$release_tag" "$release_version" >> "$GITHUB_OUTPUT"

- name: Install pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
Expand All @@ -128,22 +157,22 @@ jobs:
id: published_release
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
RELEASE_VERSION: ${{ steps.source.outputs.version }}
RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }}
run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 2400000 --interval-ms 15000

- name: Classify the stable release referenced by an ordinary main push
id: release_state
if: ${{ github.event_name == 'push' }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ steps.source.outputs.version }}
RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }}
run: node scripts/check-release-publication.mjs --version "$RELEASE_VERSION" --github-output "$GITHUB_OUTPUT"

- name: Confirm assets for an existing stable release
id: existing_release
if: ${{ github.event_name == 'push' && steps.release_state.outputs.state == 'published' }}
env:
RELEASE_VERSION: ${{ steps.source.outputs.version }}
RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }}
run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 15000 --interval-ms 3000

- name: Defer a release-version site update until publication
Expand All @@ -157,20 +186,25 @@ jobs:
env:
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ github.token }}
REQUESTED_CONTROL_SHA: ${{ inputs.control_sha }}
REQUESTED_RELEASE_COMMIT: ${{ inputs.release_commit }}
RELEASE_TAG: ${{ steps.release_identity.outputs.release_tag }}
RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }}
TRUSTED_SHA: ${{ steps.source.outputs.trusted_sha }}
REQUESTED_RELEASE_TAG: ${{ inputs.release_tag }}
TRUSTED_VERSION: ${{ steps.source.outputs.version }}
run: |
set -euo pipefail
expected_tag="v${TRUSTED_VERSION}"
release_tag="$expected_tag"
release_tag="$RELEASE_TAG"
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
if [[ "$REQUESTED_RELEASE_TAG" != "$expected_tag" ]]; then
echo "release_tag must be the current release ${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 [[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]; then
echo "workflow_dispatch must run from the immutable release tag ${expected_tag}" >&2
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 @@ -183,16 +217,17 @@ jobs:
git fetch --force origin "refs/tags/${release_tag}:refs/tags/${release_tag}"
source_sha=$(git rev-parse "${release_tag}^{commit}")
tag_version=$(git show "${source_sha}:package.json" | jq -er '.version')
if [[ "$tag_version" != "$TRUSTED_VERSION" ]]; then
echo "release tag package version ${tag_version} does not match trusted source ${TRUSTED_VERSION}" >&2
if [[ "$tag_version" != "$RELEASE_VERSION" ]]; then
echo "release tag package version ${tag_version} does not match requested version ${RELEASE_VERSION}" >&2
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 +238,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 +263,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
15 changes: 15 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -488,11 +488,26 @@ jobs:
with:
node-version: 24.13.1

- name: Resolve trusted site-control source
id: site-control
shell: bash
run: |
set -euo pipefail
git fetch --force --no-tags origin "refs/heads/main:refs/remotes/origin/main"
control_sha=$(git rev-parse refs/remotes/origin/main)
if [[ ! "$control_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "main must resolve to an immutable commit SHA" >&2
exit 1
fi
printf 'control_sha=%s\n' "$control_sha" >> "$GITHUB_OUTPUT"

- name: Dispatch and await the exact immutable production deployment
env:
CONTROL_SHA: ${{ steps.site-control.outputs.control_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"
84 changes: 67 additions & 17 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 @@ -1201,6 +1207,20 @@ export function collectReleaseConsistencyErrors(files, releaseTag) {
]) {
requireText(releaseWorkflow, expected, ".github/workflows/release.yml", errors);
}
if (
(files.get("scripts/dispatch-site-deployment.mjs") ?? "").includes(
"control_sha: controlCommit",
)
) {
for (const expected of [
"Resolve trusted site-control source",
"git rev-parse refs/remotes/origin/main",
"CONTROL_SHA: ${{ steps.site-control.outputs.control_sha }}",
'--control-commit "$CONTROL_SHA"',
]) {
requireText(releaseWorkflow, expected, ".github/workflows/release.yml", errors);
}
}
const releaseVerifyStart = releaseWorkflow.indexOf(" verify:");
const releaseAuthorityStart = releaseWorkflow.indexOf(" ci-authority:");
if (!(releaseVerifyStart >= 0 && releaseAuthorityStart > releaseVerifyStart)) {
Expand Down Expand Up @@ -1313,21 +1333,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,30 +1389,48 @@ 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,
);
}
requireText(
files.get(".github/workflows/deploy-site.yml") ?? "",
"ref: ${{ steps.immutable_source.outputs.source_sha }}",
requireOneVariant(
deploySiteWorkflow,
[
[
"startsWith(github.ref, 'refs/tags/')",
'[[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]',
'[[ "$source_sha" != "$TRUSTED_SHA" ]]',
'release_tag="$expected_tag"',
],
[
"github.ref == 'refs/heads/main'",
"release_commit:",
"control_sha:",
"Resolve requested release identity",
'release_version="${release_tag#v}"',
"RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }}",
'[[ "$GITHUB_REF" != "refs/heads/main" ]]',
'[[ "$source_sha" != "$REQUESTED_RELEASE_COMMIT" ]]',
'release_tag="$RELEASE_TAG"',
"path: .release-source",
"working-directory: .release-source",
],
],
".github/workflows/deploy-site.yml",
errors,
);
requireText(
files.get(".github/workflows/deploy-site.yml") ?? "",
'release_tag="$expected_tag"',
"ref: ${{ steps.immutable_source.outputs.source_sha }}",
".github/workflows/deploy-site.yml",
errors,
);
Expand Down
30 changes: 23 additions & 7 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,12 @@ 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("Resolve trusted site-control source"));
assert.ok(releaseWorkflow.includes("git rev-parse refs/remotes/origin/main"));
assert.ok(
releaseWorkflow.includes("CONTROL_SHA: ${{ steps.site-control.outputs.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 +817,26 @@ 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('expected_tag="v${TRUSTED_VERSION}"'));
assert.ok(deployWorkflow.includes('release_tag="$expected_tag"'));
assert.ok(deployWorkflow.includes("github.ref == 'refs/heads/main'"));
assert.ok(deployWorkflow.includes('[[ "$GITHUB_REF" != "refs/heads/main" ]]'));
assert.ok(deployWorkflow.includes("Resolve requested release identity"));
assert.ok(deployWorkflow.includes('release_version="${release_tag#v}"'));
assert.ok(
deployWorkflow.includes(
"RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }}",
),
);
assert.ok(!deployWorkflow.includes('release tag must be the current release'));
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
Loading
Loading