Skip to content

Retire CMP upstream drift workflow #188

Retire CMP upstream drift workflow

Retire CMP upstream drift workflow #188

Workflow file for this run

name: CI
on:
push:
branches: [main]
workflow_dispatch:
inputs:
release_sha:
description: Full 40-character source commit reachable from main
required: true
type: string
deploy_development:
description: Assume the application roles and mutate the development release
required: true
default: false
type: boolean
probe_development:
description: Assume application roles only for the non-mutating OIDC probe
required: true
default: false
type: boolean
operation:
description: Promote, roll back, or run the non-mutating post-bootstrap probe
required: true
default: promote
type: choice
options: [promote, rollback, probe]
failure_injection:
description: Controlled promotion-only failure drill; leave none for normal releases
required: true
default: none
type: choice
options: [none, migration, post_mutation_smoke]
reuse_existing_image:
description: Verify and reuse an already-published immutable image without Docker
required: true
default: false
type: boolean
published_image_record:
description: Compact non-secret published-image JSON; required only for reuse
required: false
type: string
prior_release_record:
description: Compact successful release JSON; empty only for first bootstrap
required: false
type: string
target_release_record:
description: Compact rollback-target release JSON; required for rollback
required: false
type: string
current_release_record:
description: Compact active release JSON; required for rollback
required: false
type: string
permissions:
contents: read
actions: read
concurrency:
group: website-development-release
cancel-in-progress: false
jobs:
resolve-release:
runs-on: ubuntu-latest
outputs:
release_sha: ${{ steps.release.outputs.release_sha }}
version: ${{ steps.release.outputs.version }}
constructed_at: ${{ steps.release.outputs.constructed_at }}
identity_schema: ${{ steps.release.outputs.identity_schema }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- run: uv sync --locked
- run: uv lock --check
- id: prior-source-identity
name: Restore the sealed source identity on a workflow rerun
if: github.run_attempt > 1 && (github.event_name != 'workflow_dispatch' || inputs.operation != 'probe')
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: release-source-identity-${{ github.run_id }}
path: .tmp/prior-release
- id: release
name: Validate immutable source selection
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_AFTER: ${{ github.event.after }}
INPUT_RELEASE_SHA: ${{ inputs.release_sha }}
INPUT_DEPLOY_DEVELOPMENT: ${{ inputs.deploy_development }}
INPUT_PROBE_DEVELOPMENT: ${{ inputs.probe_development }}
INPUT_OPERATION: ${{ inputs.operation }}
INPUT_FAILURE_INJECTION: ${{ inputs.failure_injection }}
INPUT_REUSE_EXISTING_IMAGE: ${{ inputs.reuse_existing_image }}
INPUT_PUBLISHED_IMAGE_RECORD: ${{ inputs.published_image_record }}
INPUT_PRIOR_RELEASE_RECORD: ${{ inputs.prior_release_record }}
INPUT_TARGET_RELEASE_RECORD: ${{ inputs.target_release_record }}
INPUT_CURRENT_RELEASE_RECORD: ${{ inputs.current_release_record }}
RELEASE_A_SHA: 0f0ae208526fa2e76848cf4f5a87bd4aa26687ec
RELEASE_B_SHA: e2b93beb1544170b6177ba55ea8fd6530b2e57a3
DEVELOPMENT_REPOSITORY_URI: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_URI }}
RUN_ATTEMPT: ${{ github.run_attempt }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
echo "Manual releases must use the reviewed main workflow" >&2
exit 1
fi
candidate="$INPUT_RELEASE_SHA"
case "$INPUT_REUSE_EXISTING_IMAGE" in
true|false) ;;
*) echo "reuse_existing_image must be true or false" >&2; exit 1 ;;
esac
case "$INPUT_FAILURE_INJECTION" in
none|migration|post_mutation_smoke) ;;
*) echo "Unsupported release failure injection" >&2; exit 1 ;;
esac
validate_release_record() {
uv run --frozen python -m deploy.legacy_development_compatibility \
release-record <<< "$1"
}
prior_source=""
if [[ -n "$INPUT_PRIOR_RELEASE_RECORD" ]]; then
validate_release_record "$INPUT_PRIOR_RELEASE_RECORD"
prior_source="$(jq -er '
select(type == "object") |
.source_sha |
select(type == "string" and test("^[0-9a-f]{40}$"))
' <<< "$INPUT_PRIOR_RELEASE_RECORD")"
fi
target_source=""
if [[ -n "$INPUT_TARGET_RELEASE_RECORD" ]]; then
validate_release_record "$INPUT_TARGET_RELEASE_RECORD"
target_source="$(jq -er '
select(type == "object") |
.source_sha |
select(type == "string" and test("^[0-9a-f]{40}$"))
' <<< "$INPUT_TARGET_RELEASE_RECORD")"
fi
current_source=""
if [[ -n "$INPUT_CURRENT_RELEASE_RECORD" ]]; then
validate_release_record "$INPUT_CURRENT_RELEASE_RECORD"
current_source="$(jq -er '
select(type == "object") |
.source_sha |
select(type == "string" and test("^[0-9a-f]{40}$"))
' <<< "$INPUT_CURRENT_RELEASE_RECORD")"
fi
if [[ "$INPUT_REUSE_EXISTING_IMAGE" == "true" ]]; then
if [[ -z "$INPUT_PUBLISHED_IMAGE_RECORD" ]]; then
echo "reuse requires a published-image record" >&2
exit 1
fi
elif [[ -n "$INPUT_PUBLISHED_IMAGE_RECORD" ]]; then
echo "published_image_record is accepted only when reuse_existing_image=true" >&2
exit 1
fi
if [[ "$INPUT_FAILURE_INJECTION" != "none" ]]; then
if [[ "$INPUT_OPERATION" != "promote" ]]; then
echo "Controlled failure injection is promotion-only" >&2
exit 1
fi
if [[ "$candidate" != "$RELEASE_B_SHA" ]]; then
echo "Controlled failure injection requires exact accepted release B" >&2
exit 1
fi
if [[ "$prior_source" != "$RELEASE_A_SHA" ]]; then
echo "Controlled failure injection requires exact accepted release A as prior" >&2
exit 1
fi
if [[ "$INPUT_FAILURE_INJECTION" == "post_mutation_smoke" && "$INPUT_REUSE_EXISTING_IMAGE" != "true" ]]; then
echo "The controlled B post-mutation failure must reuse the published B image" >&2
exit 1
fi
fi
if [[ "$INPUT_OPERATION" == "probe" ]]; then
if [[ "$INPUT_PROBE_DEVELOPMENT" != "true" || "$INPUT_DEPLOY_DEVELOPMENT" != "false" ]]; then
echo "Probe requires probe_development=true and deploy_development=false" >&2
exit 1
fi
if [[ "$INPUT_FAILURE_INJECTION" != "none" ]]; then
echo "Probe cannot inject a release failure" >&2
exit 1
fi
if [[ "$INPUT_REUSE_EXISTING_IMAGE" != "false" || -n "$INPUT_PUBLISHED_IMAGE_RECORD" || -n "$INPUT_PRIOR_RELEASE_RECORD" || -n "$INPUT_TARGET_RELEASE_RECORD" || -n "$INPUT_CURRENT_RELEASE_RECORD" ]]; then
echo "Probe accepts no image reuse or release record" >&2
exit 1
fi
elif [[ "$INPUT_PROBE_DEVELOPMENT" != "false" ]]; then
echo "probe_development is valid only for operation=probe" >&2
exit 1
fi
if [[ "$INPUT_OPERATION" == "rollback" ]]; then
if [[ -n "$INPUT_PRIOR_RELEASE_RECORD" ]]; then
echo "Rollback rejects the promotion prior-release input" >&2
exit 1
fi
if [[ "$INPUT_FAILURE_INJECTION" != "none" || "$INPUT_REUSE_EXISTING_IMAGE" != "true" ]]; then
echo "Rollback requires reuse with no failure injection" >&2
exit 1
fi
if [[ "$target_source" != "$candidate" || -z "$current_source" ]]; then
echo "Rollback records must identify the selected target and an active current release" >&2
exit 1
fi
if [[ "$candidate" == "$RELEASE_A_SHA" && "$current_source" != "$RELEASE_B_SHA" ]]; then
echo "The release-B-to-A drill requires exact accepted release B as current" >&2
exit 1
fi
fi
if [[ "$INPUT_OPERATION" == "promote" && ( -n "$INPUT_TARGET_RELEASE_RECORD" || -n "$INPUT_CURRENT_RELEASE_RECORD" ) ]]; then
echo "Promotion rejects rollback target/current inputs" >&2
exit 1
fi
if [[ "$INPUT_DEPLOY_DEVELOPMENT" == "true" && "$INPUT_OPERATION" == "promote" ]]; then
if [[ "$candidate" == "$RELEASE_A_SHA" ]]; then
if [[ "$INPUT_FAILURE_INJECTION" != "none" || -n "$prior_source" ]]; then
echo "Initial release A requires no prior release" >&2
exit 1
fi
elif [[ "$candidate" == "$RELEASE_B_SHA" ]]; then
if [[ "$prior_source" != "$RELEASE_A_SHA" ]]; then
echo "Every release B exercise requires exact accepted release A as prior" >&2
exit 1
fi
if [[ "$INPUT_FAILURE_INJECTION" != "migration" && "$INPUT_REUSE_EXISTING_IMAGE" != "true" ]]; then
echo "Every B run after its migration drill must reuse the published B image" >&2
exit 1
fi
fi
fi
else
candidate="$GITHUB_SHA"
if [[ "$EVENT_AFTER" != "$candidate" ]]; then
echo "Push after SHA does not match the workflow source" >&2
exit 1
fi
fi
if [[ ! "$candidate" =~ ^[0-9a-f]{40}$ ]]; then
echo "Release SHA must be exactly 40 lowercase hexadecimal characters" >&2
exit 1
fi
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$(git rev-parse HEAD)" != "$(git rev-parse refs/remotes/origin/main)" ]]; then
echo "Workflow controller is not the current main commit" >&2
exit 1
fi
if [[ "$EVENT_NAME" == "workflow_dispatch" && "$INPUT_OPERATION" == "probe" && "$candidate" != "$(git rev-parse HEAD)" ]]; then
echo "OIDC probes run only from the current main source" >&2
exit 1
fi
if ! git cat-file -e "${candidate}^{commit}"; then
echo "Release SHA is not an existing commit" >&2
exit 1
fi
if [[ "$(git rev-parse "${candidate}^{commit}")" != "$candidate" ]]; then
echo "Release SHA does not resolve exactly" >&2
exit 1
fi
if ! git merge-base --is-ancestor "$candidate" refs/remotes/origin/main; then
echo "Release SHA is not reachable from main" >&2
exit 1
fi
if [[ "$EVENT_NAME" != "workflow_dispatch" || "$INPUT_OPERATION" != "probe" ]]; then
mkdir -p .tmp/release
if (( RUN_ATTEMPT > 1 )); then
test -f .tmp/prior-release/source-identity.json
jq -e --arg sha "$candidate" '
type == "object" and
keys == ["constructed_at", "identity_schema", "source_sha", "version"] and
.source_sha == $sha and
(.identity_schema == 1 or .identity_schema == 2)
' .tmp/prior-release/source-identity.json > /dev/null
cp .tmp/prior-release/source-identity.json .tmp/release/source-identity.json
elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$INPUT_REUSE_EXISTING_IMAGE" == "true" ]]; then
printf '%s\n' "$INPUT_PUBLISHED_IMAGE_RECORD" > .tmp/release/input-published-image.json
uv run --frozen python -m deploy.release_identity inspect-published \
--allow-legacy \
--record .tmp/release/input-published-image.json \
> .tmp/release/normalized-published-image.json
jq -e \
--arg source_sha "$candidate" \
--arg repository_uri "$DEVELOPMENT_REPOSITORY_URI" '
.source_sha == $source_sha and
.repository_uri == $repository_uri and
.platform == "linux/amd64" and
.user == "10001:10001" and
.image_digest != "sha256:1111111111111111111111111111111111111111111111111111111111111111" and
.image_digest != "sha256:0000000000000000000000000000000000000000000000000000000000000000"
' .tmp/release/normalized-published-image.json > /dev/null
jq -ceS '{identity_schema, version, source_sha, constructed_at}' \
.tmp/release/normalized-published-image.json \
> .tmp/release/source-identity.json
else
uv run --frozen python -m deploy.release_identity construct \
--source-sha "$candidate" \
--output .tmp/release/source-identity.json
fi
version="$(jq -er '.version' .tmp/release/source-identity.json)"
constructed_at="$(jq -r '.constructed_at // ""' .tmp/release/source-identity.json)"
identity_schema="$(jq -er '.identity_schema' .tmp/release/source-identity.json)"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "constructed_at=$constructed_at" >> "$GITHUB_OUTPUT"
echo "identity_schema=$identity_schema" >> "$GITHUB_OUTPUT"
fi
echo "release_sha=$candidate" >> "$GITHUB_OUTPUT"
- name: Preserve the one sealed source identity
if: github.run_attempt == 1 && (github.event_name != 'workflow_dispatch' || inputs.operation != 'probe')
uses: actions/upload-artifact@v4
with:
name: release-source-identity-${{ github.run_id }}
path: .tmp/release/source-identity.json
if-no-files-found: error
retention-days: 90
classification:
if: github.event_name != 'workflow_dispatch' || inputs.operation != 'probe'
needs: resolve-release
runs-on: ubuntu-latest
# Selection, history restoration, planning, and artifact upload normally finish
# in under a minute; keep the serialized release queue bounded if they wedge.
timeout-minutes: 10
outputs:
profile: ${{ steps.selection.outputs.profile }}
reason: ${{ steps.selection.outputs.reason }}
artifact_name: ${{ steps.selection.outputs.artifact_name }}
created_attempt: ${{ steps.selection.outputs.created_attempt }}
run_id: ${{ steps.selection.outputs.run_id }}
selection_sha256: ${{ steps.selection.outputs.selection_sha256 }}
verification_profile: ${{ steps.plan.outputs.profile }}
browser_profile: ${{ steps.plan.outputs.browser_profile }}
compatibility_mode: ${{ steps.plan.outputs.compatibility_mode }}
content_invariants_mode: ${{ steps.plan.outputs.content_invariants_mode }}
container_mode: ${{ steps.plan.outputs.container_mode }}
django_mode: ${{ steps.plan.outputs.django_mode }}
playwright_mode: ${{ steps.plan.outputs.playwright_mode }}
quality_mode: ${{ steps.plan.outputs.quality_mode }}
screenshots_mode: ${{ steps.plan.outputs.screenshots_mode }}
defaults:
run:
working-directory: .tmp/ci-controller
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: .tmp/ci-controller
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve-release.outputs.release_sha }}
path: .tmp/release-source
fetch-depth: 0
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- id: selection
name: Classify the complete source change safely
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_BEFORE: ${{ github.event.before }}
EVENT_AFTER: ${{ github.event.after }}
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
CONTROLLER_SHA: ${{ github.sha }}
run: |
set -euo pipefail
mkdir -p .tmp/ci-selection
uv run --frozen python -m ci.classifier select \
--repository ../release-source \
--event "$EVENT_NAME" \
--base "$EVENT_BEFORE" \
--after "$EVENT_AFTER" \
--github-sha "$GITHUB_SHA" \
--release-sha "$RELEASE_SHA" \
--run-id "$RUN_ID" \
--created-attempt "$RUN_ATTEMPT" \
--controller-sha "$CONTROLLER_SHA" \
--source-after-sha "$EVENT_AFTER" \
--source-before-sha "$EVENT_BEFORE" \
--output .tmp/ci-selection/ci-selection.json \
--provenance-output .tmp/ci-selection/ci-selection-provenance.json \
--summary "$GITHUB_STEP_SUMMARY" \
--github-output "$GITHUB_OUTPUT"
- id: history
name: Restore bounded trusted verification evidence
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
uv run --frozen python -m ci.history \
--repository "$GITHUB_REPOSITORY" \
--token "$GITHUB_TOKEN" \
--workflow ci.yml \
--current-run-id "$GITHUB_RUN_ID" \
--output-directory .tmp/prior-evidence \
--history .tmp/ci-selection/evidence-history.json \
--github-output "$GITHUB_OUTPUT"
- id: plan
name: Build the versioned risk and evidence plan
run: |
set -euo pipefail
base="$(jq -er .base .tmp/ci-selection/ci-selection.json)"
head="$(jq -er .head .tmp/ci-selection/ci-selection.json)"
uv run --frozen python -m ci.verification plan \
--repository ../release-source \
--base "$base" --head "$head" \
--selection .tmp/ci-selection/ci-selection.json \
--evidence-directory .tmp/prior-evidence \
--history .tmp/ci-selection/evidence-history.json \
--consumer ci --release-requires-image \
--output .tmp/ci-selection/verification-plan.json \
--summary "$GITHUB_STEP_SUMMARY" \
--github-output "$GITHUB_OUTPUT"
- name: Record the classifier job's actual environment
run: |
set -euo pipefail
mkdir -p .tmp/ci-selection/components
uv run --frozen python -m ci.verification environment \
--plan .tmp/ci-selection/verification-plan.json --component selector \
--allow-hosted-runner-drift \
--output .tmp/ci-selection/components/selector-environment.json
uv run --frozen python -m ci.verification record \
--plan .tmp/ci-selection/verification-plan.json --component selector \
--result success --command "ci.classifier select and ci.verification plan" \
--origin-kind github_actions --repository-id "$GITHUB_REPOSITORY" \
--workflow .github/workflows/ci.yml --ref "$GITHUB_REF" \
--run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" \
--job-id classification \
--artifact-id "ci-selection-$GITHUB_RUN_ID-attempt-$GITHUB_RUN_ATTEMPT" \
--artifact-root .tmp/ci-selection \
--execution-environment .tmp/ci-selection/components/selector-environment.json \
--allow-hosted-runner-drift \
--machine-output .tmp/ci-selection/verification-plan.json \
--output .tmp/ci-selection/components/selector-evidence.json
- name: Carry forward only evidence selected by the exact plan
run: |
uv run --frozen python -m ci.verification materialize-reuse \
--plan .tmp/ci-selection/verification-plan.json \
--evidence-directory .tmp/prior-evidence \
--output-directory .tmp/ci-selection/components-reused
- name: Preserve the machine-readable CI selection and plan
uses: actions/upload-artifact@v4
with:
name: ci-selection-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/ci-controller/.tmp/ci-selection
if-no-files-found: error
retention-days: 30
quality:
if: github.event_name != 'workflow_dispatch' || inputs.operation != 'probe'
needs: [resolve-release, classification]
runs-on: ubuntu-latest
# The versioned quality contract normally completes in under two minutes;
# retain setup and evidence-recording headroom without an unbounded queue.
timeout-minutes: 15
defaults:
run:
working-directory: .tmp/release-source
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve-release.outputs.release_sha }}
path: .tmp/release-source
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: .tmp/ci-controller
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- name: Restore the reviewed verification plan
uses: actions/download-artifact@v4
with:
name: ci-selection-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/ci-controller/.tmp/verification-input
- run: uv sync --locked
- run: uv lock --check
- name: Run the current versioned quality contract against the selected release
working-directory: .tmp/ci-controller
run: |
set -euo pipefail
mkdir -p .tmp/components-quality
uv run --frozen python -m ci.quality_contract \
--repository ../release-source 2>&1 | \
tee .tmp/components-quality/quality-output.log
- name: Run CI orchestration contract tests from the workflow controller
working-directory: .tmp/ci-controller
run: |
set -euo pipefail
mkdir -p .tmp/components-quality
make test-ci 2>&1 | tee .tmp/components-quality/evidence-validation-output.log
- name: Build exact pinned compatibility source inputs
if: needs.classification.outputs.compatibility_mode == 'rerun'
run: |
set -euo pipefail
uv run --frozen python scripts/build_pinned_legacy_sources.py
- name: Run the compatibility contract when selected
if: needs.classification.outputs.compatibility_mode == 'rerun'
run: |
set -euo pipefail
make compatibility-source-artifacts-check compatibility-artifacts-check \
check-links check-seo 2>&1 | \
tee ../ci-controller/.tmp/components-quality/compatibility-output.log
- name: Record quality-job evidence with its actual environment
if: always() && needs.classification.result == 'success'
working-directory: .tmp/ci-controller
env:
JOB_STATUS: ${{ job.status }}
COMPATIBILITY_MODE: ${{ needs.classification.outputs.compatibility_mode }}
CONTENT_INVARIANTS_MODE: ${{ needs.classification.outputs.content_invariants_mode }}
run: |
set -euo pipefail
mkdir -p .tmp/components-quality
for component in quality compatibility content_invariants evidence_validation; do
mode="rerun"
if [[ "$component" == "compatibility" ]]; then mode="$COMPATIBILITY_MODE"; fi
if [[ "$component" == "content_invariants" ]]; then mode="$CONTENT_INVARIANTS_MODE"; fi
if [[ "$mode" != "rerun" ]]; then continue; fi
command="$(jq -er --arg component "$component" \
'.components[$component].command' .tmp/verification-input/verification-plan.json)"
artifact_args=()
machine_output=".tmp/components-quality/$component-output.log"
if [[ "$component" == "evidence_validation" ]]; then
machine_output=.tmp/components-quality/evidence-validation-output.log
fi
if [[ "$component" == "content_invariants" ]]; then
uv run --frozen python -m ci.content_invariants \
--repository ../release-source \
--plan .tmp/verification-input/verification-plan.json \
--output .tmp/components-quality/content-invariants.json
artifact_args=(--artifact .tmp/components-quality/content-invariants.json)
machine_output=.tmp/components-quality/content-invariants.json
fi
environment=.tmp/components-quality/$component-environment.json
uv run --frozen python -m ci.verification environment \
--plan .tmp/verification-input/verification-plan.json \
--component "$component" --output "$environment" \
--allow-hosted-runner-drift
uv run --frozen python -m ci.verification record \
--plan .tmp/verification-input/verification-plan.json \
--component "$component" --result "$JOB_STATUS" --command "$command" \
--origin-kind github_actions --repository-id "$GITHUB_REPOSITORY" \
--workflow .github/workflows/ci.yml --ref "$GITHUB_REF" \
--run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" \
--job-id quality \
--artifact-id "verification-component-quality-$GITHUB_RUN_ID-attempt-$GITHUB_RUN_ATTEMPT" \
--artifact-root .tmp/components-quality \
--execution-environment "$environment" \
--allow-hosted-runner-drift \
--machine-output "$machine_output" "${artifact_args[@]}" \
--output ".tmp/components-quality/$component-evidence.json"
done
- name: Preserve quality evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: verification-component-quality-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/ci-controller/.tmp/components-quality
if-no-files-found: warn
retention-days: 30
django:
if: github.event_name != 'workflow_dispatch' || inputs.operation != 'probe'
needs: [resolve-release, classification]
runs-on: ubuntu-latest
# Recent complete and focused runs fit well inside this budget, including
# database setup and terminal evidence recording.
timeout-minutes: 30
env:
DJANGO_SETTINGS_MODULE: website.settings.test
defaults:
run:
working-directory: .tmp/release-source
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve-release.outputs.release_sha }}
path: .tmp/release-source
fetch-depth: 0
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: .tmp/ci-controller
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- name: Restore the reviewed verification plan
uses: actions/download-artifact@v4
with:
name: ci-selection-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/ci-controller/.tmp/verification-input
- run: uv sync --locked
- run: uv lock --check
- name: Restore the reviewed CI selection
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: ci-selection-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/release-source/.tmp/ci-selection/current
- name: Restore attempt-one selection only for a reused classifier
if: github.run_attempt > 1 && needs.classification.outputs.created_attempt == '1'
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: ci-selection-${{ github.run_id }}-attempt-1
path: .tmp/release-source/.tmp/ci-selection/attempt-1
- name: Isolate classifier payloads for provenance resolution
working-directory: .tmp/ci-controller
run: |
set -euo pipefail
isolate_payload() {
source_directory="$1"
destination_directory="$2"
rm -rf "$destination_directory"
mkdir -p "$destination_directory"
for filename in ci-selection.json ci-selection-provenance.json; do
if [[ -f "$source_directory/$filename" ]]; then
cp "$source_directory/$filename" "$destination_directory/$filename"
fi
done
}
isolate_payload \
../release-source/.tmp/ci-selection/current \
../release-source/.tmp/ci-selection/current-payload
isolate_payload \
../release-source/.tmp/ci-selection/attempt-1 \
../release-source/.tmp/ci-selection/attempt-1-payload
- name: Resolve same-run classifier evidence
working-directory: .tmp/ci-controller
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_BEFORE: ${{ github.event.before }}
EVENT_AFTER: ${{ github.event.after }}
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
CLASSIFIER_ATTEMPT: ${{ needs.classification.outputs.created_attempt }}
PROFILE: ${{ needs.classification.outputs.profile }}
REASON: ${{ needs.classification.outputs.reason }}
SELECTION_SHA256: ${{ needs.classification.outputs.selection_sha256 }}
run: >-
uv run --frozen python -m ci.provenance resolve
--current-directory ../release-source/.tmp/ci-selection/current-payload
--fallback-directory ../release-source/.tmp/ci-selection/attempt-1-payload
--run-id "$RUN_ID"
--current-attempt "$RUN_ATTEMPT"
--classifier-created-attempt "$CLASSIFIER_ATTEMPT"
--controller-sha "$GITHUB_SHA"
--release-sha "$RELEASE_SHA"
--event "$EVENT_NAME"
--source-after-sha "$EVENT_AFTER"
--source-before-sha "$EVENT_BEFORE"
--expected-profile "$PROFILE"
--expected-reason "$REASON"
--expected-selection-sha256 "$SELECTION_SHA256"
--output-directory ../release-source/.tmp/ci-selection/resolved
--rejection-output ../release-source/.tmp/ci-selection/resolved/ci-selection-resolution.json
- name: Validate the code-owned test selection
working-directory: .tmp/ci-controller
run: >-
uv run --frozen python -m ci.classifier validate
--input ../release-source/.tmp/ci-selection/resolved/ci-selection.json
- name: Run the selected or complete Django suite
env:
CI_SELECTION_PATH: .tmp/ci-selection/resolved/ci-selection.json
SELECTED_PROFILE: ${{ needs.classification.outputs.profile }}
run: |
set -euo pipefail
mkdir -p ../ci-controller/.tmp/components-django
exec > >(tee ../ci-controller/.tmp/components-django/django-output.log) 2>&1
case "$SELECTED_PROFILE" in
focused)
make test-ci-focused
;;
full)
make test-factories
make test-migrations
make test-django-full
;;
*)
echo "Classifier emitted an unsupported profile" >&2
exit 1
;;
esac
- name: Record Django evidence with the job's actual environment
if: always() && needs.classification.result == 'success'
working-directory: .tmp/ci-controller
env:
JOB_STATUS: ${{ job.status }}
run: |
set -euo pipefail
mkdir -p .tmp/components-django
uv run --frozen python -m ci.verification environment \
--plan .tmp/verification-input/verification-plan.json \
--component django \
--allow-hosted-runner-drift \
--output .tmp/components-django/django-environment.json
uv run --frozen python -m ci.verification record \
--plan .tmp/verification-input/verification-plan.json \
--component django --result "$JOB_STATUS" \
--command "$(jq -er '.components.django.command' .tmp/verification-input/verification-plan.json)" \
--origin-kind github_actions --repository-id "$GITHUB_REPOSITORY" \
--workflow .github/workflows/ci.yml --ref "$GITHUB_REF" \
--run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" \
--job-id django \
--artifact-id "verification-component-django-$GITHUB_RUN_ID-attempt-$GITHUB_RUN_ATTEMPT" \
--artifact-root .tmp/components-django \
--execution-environment .tmp/components-django/django-environment.json \
--allow-hosted-runner-drift \
--machine-output .tmp/components-django/django-output.log \
--output .tmp/components-django/django-evidence.json
- name: Preserve Django evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: verification-component-django-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/ci-controller/.tmp/components-django
if-no-files-found: warn
retention-days: 30
playwright:
if: github.event_name != 'workflow_dispatch' || inputs.operation != 'probe'
needs: [resolve-release, classification]
runs-on: ubuntu-latest
# The verified full-profile suite currently takes 48m51s on the hosted
# runner (issue #206). Keep a bounded reserve above that measurement so a
# healthy release reaches ci-gate, while a wedged test still cannot hold
# the serialized release group for GitHub's 360-minute default (issue #193).
timeout-minutes: 60
env:
DJANGO_ALLOW_ASYNC_UNSAFE: "true"
DJANGO_SETTINGS_MODULE: website.settings.test
defaults:
run:
working-directory: .tmp/release-source
steps:
- uses: actions/checkout@v4
if: needs.classification.outputs.playwright_mode == 'rerun'
with:
ref: ${{ needs.resolve-release.outputs.release_sha }}
path: .tmp/release-source
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: .tmp/ci-controller
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- name: Restore the reviewed verification plan
uses: actions/download-artifact@v4
with:
name: ci-selection-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/ci-controller/.tmp/verification-input
- run: uv sync --locked
if: needs.classification.outputs.playwright_mode == 'rerun'
- run: uv lock --check
if: needs.classification.outputs.playwright_mode == 'rerun'
- run: uv run playwright install --with-deps chromium
if: needs.classification.outputs.playwright_mode == 'rerun'
- name: Run the selected browser profile
if: needs.classification.outputs.playwright_mode == 'rerun'
env:
BROWSER_PROFILE: ${{ needs.classification.outputs.browser_profile }}
run: |
set -euo pipefail
mkdir -p ../ci-controller/.tmp/components-playwright
if [[ "$BROWSER_PROFILE" == "full" ]]; then
make test-playwright 2>&1 | \
tee ../ci-controller/.tmp/components-playwright/playwright-output.log
else
make test-playwright-core 2>&1 | \
tee ../ci-controller/.tmp/components-playwright/playwright-output.log
fi
- name: Record Playwright evidence with the job's actual environment
if: >-
always() &&
needs.classification.result == 'success' &&
needs.classification.outputs.playwright_mode == 'rerun'
working-directory: .tmp/ci-controller
env:
JOB_STATUS: ${{ job.status }}
run: |
set -euo pipefail
mkdir -p .tmp/components-playwright
uv run --frozen python -m ci.verification environment \
--plan .tmp/verification-input/verification-plan.json \
--component playwright \
--allow-hosted-runner-drift \
--output .tmp/components-playwright/playwright-environment.json
uv run --frozen python -m ci.verification record \
--plan .tmp/verification-input/verification-plan.json \
--component playwright --result "$JOB_STATUS" \
--command "$(jq -er '.components.playwright.command' .tmp/verification-input/verification-plan.json)" \
--origin-kind github_actions --repository-id "$GITHUB_REPOSITORY" \
--workflow .github/workflows/ci.yml --ref "$GITHUB_REF" \
--run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" \
--job-id playwright \
--artifact-id "verification-component-playwright-$GITHUB_RUN_ID-attempt-$GITHUB_RUN_ATTEMPT" \
--artifact-root .tmp/components-playwright \
--execution-environment .tmp/components-playwright/playwright-environment.json \
--allow-hosted-runner-drift \
--machine-output .tmp/components-playwright/playwright-output.log \
--output .tmp/components-playwright/playwright-evidence.json
- name: Preserve Playwright evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: verification-component-playwright-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/ci-controller/.tmp/components-playwright
if-no-files-found: warn
retention-days: 30
screenshots:
if: github.event_name != 'workflow_dispatch' || inputs.operation != 'probe'
needs: [resolve-release, classification]
runs-on: ubuntu-latest
# Same wedge guard as the playwright job: the capture runtime drives the same
# browser stack and shares the serialized release concurrency group (issue #193).
timeout-minutes: 30
env:
DJANGO_ALLOW_ASYNC_UNSAFE: "true"
DJANGO_SETTINGS_MODULE: website.settings.test
defaults:
run:
working-directory: .tmp/release-source
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve-release.outputs.release_sha }}
path: .tmp/release-source
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: .tmp/ci-controller
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- name: Restore the reviewed verification plan
uses: actions/download-artifact@v4
with:
name: ci-selection-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/ci-controller/.tmp/verification-input
- name: Establish the release application environment
if: needs.classification.outputs.screenshots_mode == 'rerun'
run: uv sync --locked
- name: Establish the screenshot controller environment
if: needs.classification.outputs.screenshots_mode == 'rerun'
working-directory: .tmp/ci-controller
run: uv sync --locked
- name: Verify the release and controller lockfiles
if: needs.classification.outputs.screenshots_mode == 'rerun'
run: |
uv lock --check
(cd ../ci-controller && uv lock --check)
- name: Install the pinned Chromium browser
if: needs.classification.outputs.screenshots_mode == 'rerun'
working-directory: .tmp/ci-controller
run: uv run --frozen playwright install --with-deps chromium
- name: Migrate and capture with one owned SQLite runtime
if: needs.classification.outputs.screenshots_mode == 'rerun'
run: |
set -euo pipefail
mkdir -p ../ci-controller/.tmp/components-screenshots
uv run --frozen python -m ci.screenshot_runtime \
--repository . \
--controller-repository ../ci-controller \
--plan ../ci-controller/.tmp/verification-input/verification-plan.json \
--base-url http://127.0.0.1:8000 \
--output ../ci-controller/.tmp/components-screenshots \
--server-log ../ci-controller/.tmp/components-screenshots/server.log
- name: Record screenshot evidence with the job's actual environment
if: >-
always() &&
needs.classification.result == 'success' &&
needs.classification.outputs.screenshots_mode == 'rerun'
working-directory: .tmp/ci-controller
env:
JOB_STATUS: ${{ job.status }}
run: |
set -euo pipefail
mkdir -p .tmp/components-screenshots
uv run --frozen python -m ci.verification environment \
--plan .tmp/verification-input/verification-plan.json \
--component screenshots \
--allow-hosted-runner-drift \
--output .tmp/components-screenshots/screenshots-environment.json
artifact_args=()
for image in .tmp/components-screenshots/screenshots/*.png; do
artifact_args+=(--artifact "$image")
done
uv run --frozen python -m ci.verification record \
--plan .tmp/verification-input/verification-plan.json \
--component screenshots --result "$JOB_STATUS" \
--command "$(jq -er '.components.screenshots.command' .tmp/verification-input/verification-plan.json)" \
--origin-kind github_actions --repository-id "$GITHUB_REPOSITORY" \
--workflow .github/workflows/ci.yml --ref "$GITHUB_REF" \
--run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" \
--job-id screenshots \
--artifact-id "verification-component-screenshots-$GITHUB_RUN_ID-attempt-$GITHUB_RUN_ATTEMPT" \
--artifact-root .tmp/components-screenshots \
--execution-environment .tmp/components-screenshots/screenshots-environment.json \
--allow-hosted-runner-drift \
--machine-output .tmp/components-screenshots/screenshots.json \
--screenshot .tmp/components-screenshots/screenshots.json \
"${artifact_args[@]}" \
--output .tmp/components-screenshots/screenshots-evidence.json
- name: Keep screenshot component successful when render inputs are unchanged
if: needs.classification.outputs.screenshots_mode != 'rerun'
run: echo "No render-impact changes; screenshot evidence is not applicable."
- name: Preserve screenshot evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: verification-component-screenshots-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/ci-controller/.tmp/components-screenshots
if-no-files-found: warn
retention-days: 30
container:
if: github.event_name != 'workflow_dispatch' || inputs.operation != 'probe'
needs: [resolve-release, classification]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: .tmp/ci-controller
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- name: Establish the locked controller environment
working-directory: .tmp/ci-controller
run: uv sync --locked
- name: Verify the controller lockfile
working-directory: .tmp/ci-controller
run: uv lock --check
- name: Restore the reviewed verification plan
uses: actions/download-artifact@v4
with:
name: ci-selection-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/ci-controller/.tmp/verification-input
- id: image-cache
name: Restore the immutable tested-image cache for an automatic rerun
if: github.event_name == 'push' || inputs.reuse_existing_image == false
uses: actions/cache@v4
with:
path: .tmp/release-image.tar
key: tested-release-image-${{ needs.resolve-release.outputs.release_sha }}
- name: Reject a rerun when its immutable image cache is missing
if: >-
github.run_attempt > 1 &&
(github.event_name == 'push' || inputs.reuse_existing_image == false) &&
steps.image-cache.outputs.cache-hit != 'true'
run: |
echo "Automatic reruns may only restore and reverify the first attempt's tested image." >&2
exit 1
- name: Complete the container gate without Docker for immutable-image reuse
if: github.event_name == 'workflow_dispatch' && inputs.reuse_existing_image == true
env:
PUBLISHED_IMAGE_RECORD: ${{ inputs.published_image_record }}
run: |
set -euo pipefail
test -n "$PUBLISHED_IMAGE_RECORD"
mkdir -p .tmp/ci-controller/.tmp/components-container
printf '%s\n' published_image_record_supplied immutable_image_reuse_selected >> \
.tmp/ci-controller/.tmp/components-container/container-assertions.txt
- uses: actions/checkout@v4
if: >-
(github.event_name != 'workflow_dispatch' || inputs.reuse_existing_image == false) &&
steps.image-cache.outputs.cache-hit != 'true'
with:
ref: ${{ needs.resolve-release.outputs.release_sha }}
path: .tmp/release-source
- name: Build the production image once for development architecture
if: >-
(github.event_name != 'workflow_dispatch' || inputs.reuse_existing_image == false) &&
steps.image-cache.outputs.cache-hit != 'true'
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
VERSION: ${{ needs.resolve-release.outputs.version }}
CONSTRUCTED_AT: ${{ needs.resolve-release.outputs.constructed_at }}
run: |
set -euo pipefail
docker buildx build \
--platform linux/amd64 \
--provenance=false \
--label "org.opencontainers.image.version=$VERSION" \
--label "org.opencontainers.image.revision=$RELEASE_SHA" \
--label "org.opencontainers.image.created=$CONSTRUCTED_AT" \
--tag "dtc-website:$RELEASE_SHA" \
--load \
.tmp/release-source
- name: Load the immutable tested image without rebuilding on a cache hit
if: >-
(github.event_name != 'workflow_dispatch' || inputs.reuse_existing_image == false) &&
steps.image-cache.outputs.cache-hit == 'true'
run: docker image load --input .tmp/release-image.tar
- name: Verify image provenance, architecture, and exact runtime user
if: github.event_name != 'workflow_dispatch' || inputs.reuse_existing_image == false
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
VERSION: ${{ needs.resolve-release.outputs.version }}
CONSTRUCTED_AT: ${{ needs.resolve-release.outputs.constructed_at }}
run: |
set -euo pipefail
image="dtc-website:$RELEASE_SHA"
test "$(docker image inspect --format '{{.Architecture}}' "$image")" = "amd64"
test "$(docker image inspect --format '{{.Config.User}}' "$image")" = "10001:10001"
test "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image")" = "$RELEASE_SHA"
test "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$image")" = "$VERSION"
test "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.created" }}' "$image")" = "$CONSTRUCTED_AT"
test "$(docker run --rm --entrypoint id "$image" -u)" = "10001"
test "$(docker run --rm --entrypoint id "$image" -g)" = "10001"
mkdir -p .tmp/ci-controller/.tmp/components-container
printf '%s\n' image_architecture_amd64 image_revision_label_matches_source \
image_version_label_matches_release image_created_label_matches_release \
image_runtime_user_configured_non_root runtime_uid_10001 runtime_gid_10001 >> \
.tmp/ci-controller/.tmp/components-container/container-assertions.txt
- name: Verify the built runtime static manifest
if: github.event_name != 'workflow_dispatch' || inputs.reuse_existing_image == false
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
image="dtc-website:$RELEASE_SHA"
fixtures="$PWD/.tmp/static-manifest-fixtures"
mkdir -p "$fixtures/absent"
printf '{\n' > "$fixtures/malformed.json"
printf '{"paths": {}, "version": "1.1", "hash": "fixture"}\n' > "$fixtures/missing-entry.json"
verify_manifest() {
settings_module="$1"
shift
docker run --rm \
--entrypoint sh \
--env "DJANGO_SETTINGS_MODULE=$settings_module" \
"$@" \
"$image" -c 'uv run --no-sync python -m scripts.verify_static_manifest'
}
verify_manifest website.settings.collectstatic
if verify_manifest website.settings.collectstatic \
--mount "type=bind,source=$fixtures/absent,target=/app/staticfiles,readonly"; then
echo "Static verification accepted an absent manifest." >&2
exit 1
fi
if verify_manifest website.settings.collectstatic \
--mount "type=bind,source=$fixtures/malformed.json,target=/app/staticfiles/staticfiles.json,readonly"; then
echo "Static verification accepted a malformed manifest." >&2
exit 1
fi
if verify_manifest website.settings.collectstatic \
--mount "type=bind,source=$fixtures/missing-entry.json,target=/app/staticfiles/staticfiles.json,readonly"; then
echo "Static verification accepted a manifest without courses.css." >&2
exit 1
fi
set +e
incompatible_output="$(
docker run --rm \
--entrypoint sh \
--env "DJANGO_SETTINGS_MODULE=website.settings.collectstatic" \
"$image" -c \
'uv run --no-sync python -m scripts.verify_static_manifest --incompatible-storage-fixture' \
2>&1
)"
incompatible_status=$?
set -e
if [ "$incompatible_status" -ne 1 ] || \
[ "$incompatible_output" != "Static manifest verification failed: staticfiles storage does not use the runtime manifest backend" ]; then
echo "Static verification did not return the exact incompatible-backend diagnostic." >&2
printf '%s\n' "$incompatible_output" >&2
exit 1
fi
printf '%s\n' static_manifest_valid static_manifest_absent_rejected \
static_manifest_malformed_rejected static_manifest_missing_entry_rejected \
static_manifest_incompatible_storage_rejected >> \
.tmp/ci-controller/.tmp/components-container/container-assertions.txt
- name: Smoke-test liveness without publishing
if: github.event_name != 'workflow_dispatch' || inputs.reuse_existing_image == false
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
VERSION: ${{ needs.resolve-release.outputs.version }}
TEST_IMAGE_DIGEST: sha256:2222222222222222222222222222222222222222222222222222222222222222
run: |
set -euo pipefail
docker run --detach --name dtc-web \
--env DJANGO_SETTINGS_MODULE=website.settings.local \
--env "VERSION=$VERSION" \
--env "SOURCE_SHA=$RELEASE_SHA" \
--env "IMAGE_DIGEST=$TEST_IMAGE_DIGEST" \
--publish 8000:8000 "dtc-website:$RELEASE_SHA" web
trap 'docker logs dtc-web; docker rm --force dtc-web' EXIT
for _ in $(seq 1 30); do
if curl --fail --silent http://127.0.0.1:8000/health/live | jq -e \
--arg version "$VERSION" --arg sha "$RELEASE_SHA" --arg digest "$TEST_IMAGE_DIGEST" \
'. == {status: "ok", version: $version, source_sha: $sha, image_digest: $digest}' > /dev/null; then
curl --fail --silent --output /dev/null http://127.0.0.1:8000/unified/
printf '%s\n' health_live_exact_release_identity unified_route_responds >> \
.tmp/ci-controller/.tmp/components-container/container-assertions.txt
exit 0
fi
sleep 1
done
exit 1
- name: Preserve the one tested image
if: >-
(github.event_name != 'workflow_dispatch' || inputs.reuse_existing_image == false) &&
steps.image-cache.outputs.cache-hit != 'true'
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: docker image save --output .tmp/release-image.tar "dtc-website:$RELEASE_SHA"
- uses: actions/upload-artifact@v4
if: github.event_name != 'workflow_dispatch' || inputs.reuse_existing_image == false
with:
name: release-image-${{ needs.resolve-release.outputs.release_sha }}
path: .tmp/release-image.tar
compression-level: 0
retention-days: 1
if-no-files-found: error
overwrite: true
- name: Record production-container evidence with the job's actual environment
if: always() && needs.classification.result == 'success'
working-directory: .tmp/ci-controller
env:
JOB_STATUS: ${{ job.status }}
run: |
set -euo pipefail
mkdir -p .tmp/components-container
touch .tmp/components-container/container-assertions.txt
jq -Rn \
--arg revision "${{ needs.resolve-release.outputs.release_sha }}" \
--arg status "$JOB_STATUS" \
'[inputs | select(length > 0)] as $assertions | {
assertions: $assertions,
revision: $revision,
schema_version: 1,
status: (if $status == "success" then "pass" else "failure" end)
}' < .tmp/components-container/container-assertions.txt > \
.tmp/components-container/container-check.json
uv run --frozen python -m ci.verification environment \
--plan .tmp/verification-input/verification-plan.json \
--component container \
--allow-hosted-runner-drift \
--output .tmp/components-container/container-environment.json
uv run --frozen python -m ci.verification record \
--plan .tmp/verification-input/verification-plan.json \
--component container --result "$JOB_STATUS" \
--command "$(jq -er '.components.container.command' .tmp/verification-input/verification-plan.json)" \
--origin-kind github_actions --repository-id "$GITHUB_REPOSITORY" \
--workflow .github/workflows/ci.yml --ref "$GITHUB_REF" \
--run-id "$GITHUB_RUN_ID" --run-attempt "$GITHUB_RUN_ATTEMPT" \
--job-id container \
--artifact-id "verification-component-container-$GITHUB_RUN_ID-attempt-$GITHUB_RUN_ATTEMPT" \
--artifact-root .tmp/components-container \
--execution-environment .tmp/components-container/container-environment.json \
--allow-hosted-runner-drift \
--machine-output .tmp/components-container/container-check.json \
--output .tmp/components-container/container-evidence.json
- name: Preserve production-container evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: verification-component-container-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/ci-controller/.tmp/components-container
if-no-files-found: warn
retention-days: 30
ci-gate:
if: >-
always() &&
(github.event_name != 'workflow_dispatch' || inputs.operation != 'probe')
# Preserve the release-image prerequisite boundary checked by the frozen
# deployment contract: needs: [resolve-release, quality, django, playwright, container, screenshots]
needs: [resolve-release, classification, quality, django, playwright, container, screenshots]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- id: selection-download
name: Restore classifier plan and evidence for aggregate validation
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: ci-selection-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/verification-input
- name: Restore attempt-one selection only for a reused classifier
if: github.run_attempt > 1 && needs.classification.outputs.created_attempt == '1'
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: ci-selection-${{ github.run_id }}-attempt-1
path: .tmp/verification-input-attempt-1
- name: Isolate classifier payloads for provenance resolution
run: |
set -euo pipefail
isolate_payload() {
source_directory="$1"
destination_directory="$2"
rm -rf "$destination_directory"
mkdir -p "$destination_directory"
for filename in ci-selection.json ci-selection-provenance.json; do
if [[ -f "$source_directory/$filename" ]]; then
cp "$source_directory/$filename" "$destination_directory/$filename"
fi
done
}
isolate_payload \
.tmp/verification-input \
.tmp/ci-selection/current-payload
isolate_payload \
.tmp/verification-input-attempt-1 \
.tmp/ci-selection/attempt-1-payload
- name: Resolve same-run classifier evidence
continue-on-error: true
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_BEFORE: ${{ github.event.before }}
EVENT_AFTER: ${{ github.event.after }}
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
CLASSIFIER_ATTEMPT: ${{ needs.classification.outputs.created_attempt }}
PROFILE: ${{ needs.classification.outputs.profile }}
REASON: ${{ needs.classification.outputs.reason }}
SELECTION_SHA256: ${{ needs.classification.outputs.selection_sha256 }}
run: >-
uv run --frozen python -m ci.provenance resolve
--current-directory .tmp/ci-selection/current-payload
--fallback-directory .tmp/ci-selection/attempt-1-payload
--run-id "$RUN_ID"
--current-attempt "$RUN_ATTEMPT"
--classifier-created-attempt "$CLASSIFIER_ATTEMPT"
--controller-sha "$GITHUB_SHA"
--release-sha "$RELEASE_SHA"
--event "$EVENT_NAME"
--source-after-sha "$EVENT_AFTER"
--source-before-sha "$EVENT_BEFORE"
--expected-profile "$PROFILE"
--expected-reason "$REASON"
--expected-selection-sha256 "$SELECTION_SHA256"
--output-directory .tmp/ci-selection/resolved
--rejection-output .tmp/ci-selection/resolved/ci-selection-resolution.json
- name: Restore fresh component evidence for aggregate validation
continue-on-error: true
uses: actions/download-artifact@v4
with:
pattern: verification-component-*-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/verification-input/components
merge-multiple: true
- name: Build the exhaustive verification report
run: |
uv run --frozen python -m ci.verification report \
--plan .tmp/verification-input/verification-plan.json \
--result-directory .tmp/verification-input \
--phase ci --no-fail-exit \
--output .tmp/verification-input/verification-report.json \
--summary "$GITHUB_STEP_SUMMARY"
- name: Require every non-probe release check
env:
RESOLVE_RESULT: ${{ needs.resolve-release.result }}
CLASSIFICATION_RESULT: ${{ needs.classification.result }}
QUALITY_RESULT: ${{ needs.quality.result }}
DJANGO_RESULT: ${{ needs.django.result }}
PLAYWRIGHT_RESULT: ${{ needs.playwright.result }}
CONTAINER_RESULT: ${{ needs.container.result }}
SCREENSHOTS_RESULT: ${{ needs.screenshots.result }}
PROFILE: ${{ needs.classification.outputs.profile }}
REASON: ${{ needs.classification.outputs.reason }}
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
EVENT_NAME: ${{ github.event_name }}
EVENT_AFTER: ${{ github.event.after }}
EVENT_BEFORE: ${{ github.event.before }}
SELECTION_SHA256: ${{ needs.classification.outputs.selection_sha256 }}
run: |
uv run --frozen python -m ci.gate normal \
--selection .tmp/ci-selection/resolved/ci-selection.json \
--evidence .tmp/ci-selection/resolved/ci-selection-resolution.json \
--expected-profile "$PROFILE" \
--expected-reason "$REASON" \
--expected-controller-sha "$GITHUB_SHA" \
--expected-release-sha "$RELEASE_SHA" \
--expected-run-id "$RUN_ID" \
--expected-attempt "$RUN_ATTEMPT" \
--expected-event "$EVENT_NAME" \
--expected-source-after-sha "$EVENT_AFTER" \
--expected-source-before-sha "$EVENT_BEFORE" \
--expected-selection-sha256 "$SELECTION_SHA256" \
--verification-plan .tmp/verification-input/verification-plan.json \
--verification-report .tmp/verification-input/verification-report.json \
--verification-evidence-directory .tmp/verification-input \
--resolve-release "$RESOLVE_RESULT" \
--classification "$CLASSIFICATION_RESULT" \
--quality "$QUALITY_RESULT" \
--django "$DJANGO_RESULT" \
--playwright "$PLAYWRIGHT_RESULT" \
--container "$CONTAINER_RESULT" \
--screenshots "$SCREENSHOTS_RESULT" \
--output .tmp/ci-gate.json \
--summary "$GITHUB_STEP_SUMMARY"
- name: Preserve aggregate CI evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: verification-evidence-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: |
.tmp/ci-gate.json
.tmp/verification-input
if-no-files-found: warn
retention-days: 30
auto-capture-prior:
if: >-
needs.ci-gate.result == 'success' &&
github.event_name == 'push' &&
vars.DEVELOPMENT_AUTO_DEPLOY == 'true' &&
github.ref == 'refs/heads/main'
needs: [resolve-release, classification, quality, django, playwright, container, ci-gate]
runs-on: ubuntu-latest
environment:
name: sandbox
permissions:
contents: read
id-token: write
outputs:
active_service_pair: ${{ steps.capture.outputs.active_service_pair }}
before_oidc_at: ${{ steps.capture-before-oidc.outputs.verified_at }}
before_capture_at: ${{ steps.capture-before-read.outputs.verified_at }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- run: uv sync --locked
- run: uv lock --check
- name: Validate automatic prior-capture configuration
env:
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
DEPLOYER_ROLE_ARN: ${{ vars.DEVELOPMENT_DEPLOYER_ROLE_ARN }}
ECR_REPOSITORY_URI: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_URI }}
ECS_CLUSTER_ARN: ${{ vars.DEVELOPMENT_ECS_CLUSTER_ARN }}
WEB_TARGET_GROUP_ARN: ${{ vars.DEVELOPMENT_WEB_TARGET_GROUP_ARN }}
WEB_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WEB_SERVICE_NAME }}
WORKER_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WORKER_SERVICE_NAME }}
WEB_FAMILY: ${{ vars.DEVELOPMENT_ECS_WEB_TASK_FAMILY }}
WORKER_FAMILY: ${{ vars.DEVELOPMENT_ECS_WORKER_TASK_FAMILY }}
MIGRATION_FAMILY: ${{ vars.DEVELOPMENT_ECS_MIGRATION_TASK_FAMILY }}
CONTAINER_NAMES: ${{ vars.DEVELOPMENT_ECS_CONTAINER_NAMES }}
TASK_ROLE_ARN: ${{ vars.DEVELOPMENT_ECS_TASK_ROLE_ARN }}
EXECUTION_ROLE_ARN: ${{ vars.DEVELOPMENT_ECS_EXECUTION_ROLE_ARN }}
ECS_SUBNET_IDS: ${{ vars.DEVELOPMENT_ECS_SUBNET_IDS }}
ECS_SECURITY_GROUP_IDS: ${{ vars.DEVELOPMENT_ECS_SECURITY_GROUP_IDS }}
ASSIGN_PUBLIC_IP: ${{ vars.DEVELOPMENT_ECS_ASSIGN_PUBLIC_IP }}
WEB_DESIRED_COUNT: ${{ vars.DEVELOPMENT_WEB_RELEASE_DESIRED_COUNT }}
WORKER_DESIRED_COUNT: ${{ vars.DEVELOPMENT_WORKER_RELEASE_DESIRED_COUNT }}
run: |
set -euo pipefail
uv run --frozen python -m deploy.legacy_development_compatibility prior-capture
- id: capture-before-oidc
name: Verify current-main controller immediately before prior-capture OIDC
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
test "$(git rev-parse HEAD)" = "$RELEASE_SHA"
test "$GITHUB_SHA" = "$RELEASE_SHA"
test "$(git rev-parse refs/remotes/origin/main)" = "$RELEASE_SHA"
echo "verified_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DEVELOPMENT_DEPLOYER_ROLE_ARN }}
aws-region: ${{ vars.DEVELOPMENT_AWS_REGION }}
role-session-name: website-prior-capture-${{ github.run_id }}
- id: capture-before-read
name: Recheck current-main controller immediately before active-pair capture
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
test "$(git rev-parse HEAD)" = "$RELEASE_SHA"
test "$GITHUB_SHA" = "$RELEASE_SHA"
test "$(git rev-parse refs/remotes/origin/main)" = "$RELEASE_SHA"
echo "verified_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- id: capture
name: Capture the exact managed active web-worker pair
env:
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
REPOSITORY_URI: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_URI }}
CLUSTER_ARN: ${{ vars.DEVELOPMENT_ECS_CLUSTER_ARN }}
WEB_TARGET_GROUP_ARN: ${{ vars.DEVELOPMENT_WEB_TARGET_GROUP_ARN }}
WEB_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WEB_SERVICE_NAME }}
WORKER_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WORKER_SERVICE_NAME }}
WEB_FAMILY: ${{ vars.DEVELOPMENT_ECS_WEB_TASK_FAMILY }}
WORKER_FAMILY: ${{ vars.DEVELOPMENT_ECS_WORKER_TASK_FAMILY }}
MIGRATION_FAMILY: ${{ vars.DEVELOPMENT_ECS_MIGRATION_TASK_FAMILY }}
CONTAINER_NAMES: ${{ vars.DEVELOPMENT_ECS_CONTAINER_NAMES }}
TASK_ROLE_ARN: ${{ vars.DEVELOPMENT_ECS_TASK_ROLE_ARN }}
EXECUTION_ROLE_ARN: ${{ vars.DEVELOPMENT_ECS_EXECUTION_ROLE_ARN }}
SUBNET_IDS: ${{ vars.DEVELOPMENT_ECS_SUBNET_IDS }}
SECURITY_GROUP_IDS: ${{ vars.DEVELOPMENT_ECS_SECURITY_GROUP_IDS }}
ASSIGN_PUBLIC_IP: ${{ vars.DEVELOPMENT_ECS_ASSIGN_PUBLIC_IP }}
WEB_DESIRED_COUNT: ${{ vars.DEVELOPMENT_WEB_RELEASE_DESIRED_COUNT }}
WORKER_DESIRED_COUNT: ${{ vars.DEVELOPMENT_WORKER_RELEASE_DESIRED_COUNT }}
run: |
set -euo pipefail
mkdir -p .tmp/deployment
mapfile -t subnet_ids < <(jq -er '.[]' <<< "$SUBNET_IDS")
mapfile -t security_group_ids < <(jq -er '.[]' <<< "$SECURITY_GROUP_IDS")
runtime_args=(
--region "$AWS_REGION"
--cluster-arn "$CLUSTER_ARN"
--web-target-group-arn "$WEB_TARGET_GROUP_ARN"
--web-service-name "$WEB_SERVICE_NAME"
--worker-service-name "$WORKER_SERVICE_NAME"
--web-family "$WEB_FAMILY"
--worker-family "$WORKER_FAMILY"
--migration-family "$MIGRATION_FAMILY"
--web-container-name "$(jq -er '.web' <<< "$CONTAINER_NAMES")"
--worker-container-name "$(jq -er '.worker' <<< "$CONTAINER_NAMES")"
--migration-container-name "$(jq -er '.migration' <<< "$CONTAINER_NAMES")"
--task-role-arn "$TASK_ROLE_ARN"
--execution-role-arn "$EXECUTION_ROLE_ARN"
--assign-public-ip "$ASSIGN_PUBLIC_IP"
)
for subnet_id in "${subnet_ids[@]}"; do runtime_args+=(--subnet-id "$subnet_id"); done
for security_group_id in "${security_group_ids[@]}"; do
runtime_args+=(--security-group-id "$security_group_id")
done
uv run python -m deploy.cli capture-current \
"${runtime_args[@]}" \
--repository-uri "$REPOSITORY_URI" \
--expected-web-count "$WEB_DESIRED_COUNT" \
--expected-worker-count "$WORKER_DESIRED_COUNT" \
--release-record-path .tmp/deployment/active-service-pair.json \
--evidence-path .tmp/deployment/capture-evidence.json
prior_source="$(jq -er '.source_sha' .tmp/deployment/active-service-pair.json)"
git cat-file -e "${prior_source}^{commit}"
test "$(git rev-parse "${prior_source}^{commit}")" = "$prior_source"
git merge-base --is-ancestor "$prior_source" refs/remotes/origin/main
pair="$(jq -c . .tmp/deployment/active-service-pair.json)"
echo "active_service_pair=$pair" >> "$GITHUB_OUTPUT"
- name: Preserve redacted automatic capture evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: development-capture-evidence-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/deployment/capture-evidence.json
if-no-files-found: warn
retention-days: 90
publish:
if: >-
always() &&
needs.ci-gate.result == 'success' &&
((github.event_name == 'push' && vars.DEVELOPMENT_AUTO_DEPLOY == 'true' &&
needs.auto-capture-prior.result == 'success') ||
(github.event_name == 'workflow_dispatch' && inputs.deploy_development == true &&
inputs.operation != 'probe' && needs.auto-capture-prior.result == 'skipped')) &&
github.ref == 'refs/heads/main'
needs: [resolve-release, classification, quality, django, playwright, container, ci-gate, auto-capture-prior]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
outputs:
image_digest: ${{ steps.image.outputs.image_digest }}
before_oidc_at: ${{ steps.publisher-before-oidc.outputs.verified_at }}
before_publish_at: ${{ steps.publisher-before-write.outputs.verified_at }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- run: uv sync --locked
- run: uv lock --check
- name: Restore the sealed source identity
uses: actions/download-artifact@v4
with:
name: release-source-identity-${{ github.run_id }}
path: .tmp/release
- uses: actions/download-artifact@v4
if: github.event_name == 'push' || inputs.reuse_existing_image == false
with:
name: release-image-${{ needs.resolve-release.outputs.release_sha }}
path: .tmp
- name: Validate publisher configuration
env:
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
ECR_REPOSITORY_URI: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_URI }}
ECR_REPOSITORY_NAME: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_NAME }}
PUBLISHER_ROLE_ARN: ${{ vars.DEVELOPMENT_PUBLISHER_ROLE_ARN }}
PUBLISHED_IMAGE_RECORD: ${{ inputs.published_image_record }}
REUSE_EXISTING_IMAGE: ${{ inputs.reuse_existing_image }}
run: |
set -euo pipefail
uv run --frozen python -m deploy.legacy_development_compatibility publisher
if [[ "$REUSE_EXISTING_IMAGE" == "true" ]]; then
test "$(jq -er '.repository_uri' <<< "$PUBLISHED_IMAGE_RECORD")" = "$ECR_REPOSITORY_URI"
fi
- id: publisher-before-oidc
name: Verify current-main controller immediately before publisher OIDC
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
controller="$(git rev-parse HEAD)"
test "$controller" = "$(git rev-parse refs/remotes/origin/main)"
if [[ "$EVENT_NAME" == "push" ]]; then
test "$controller" = "$RELEASE_SHA"
test "$GITHUB_SHA" = "$RELEASE_SHA"
fi
echo "verified_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DEVELOPMENT_PUBLISHER_ROLE_ARN }}
aws-region: ${{ vars.DEVELOPMENT_AWS_REGION }}
role-session-name: website-publisher-${{ github.run_id }}
- id: publisher-before-write
name: Recheck current-main controller immediately before ECR access
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
controller="$(git rev-parse HEAD)"
test "$controller" = "$(git rev-parse refs/remotes/origin/main)"
if [[ "$EVENT_NAME" == "push" ]]; then
test "$controller" = "$RELEASE_SHA"
test "$GITHUB_SHA" = "$RELEASE_SHA"
fi
echo "verified_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Push once and write the non-release published-image record
if: github.event_name == 'push' || inputs.reuse_existing_image == false
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
VERSION: ${{ needs.resolve-release.outputs.version }}
CONSTRUCTED_AT: ${{ needs.resolve-release.outputs.constructed_at }}
ECR_REPOSITORY_URI: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_URI }}
ECR_REPOSITORY_NAME: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_NAME }}
run: |
set -euo pipefail
docker image load --input .tmp/release-image.tar
local_config_digest="$(docker image inspect --format '{{.Id}}' "dtc-website:$RELEASE_SHA")"
test "$(docker image inspect --format '{{.Architecture}}' "dtc-website:$RELEASE_SHA")" = "amd64"
test "$(docker image inspect --format '{{.Config.User}}' "dtc-website:$RELEASE_SHA")" = "10001:10001"
test "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "dtc-website:$RELEASE_SHA")" = "$VERSION"
test "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "dtc-website:$RELEASE_SHA")" = "$RELEASE_SHA"
test "$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.created" }}' "dtc-website:$RELEASE_SHA")" = "$CONSTRUCTED_AT"
[[ "$local_config_digest" =~ ^sha256:[0-9a-f]{64}$ ]]
if aws ecr describe-images \
--repository-name "$ECR_REPOSITORY_NAME" \
--image-ids "imageTag=$RELEASE_SHA" \
> .tmp/ecr-existing.json 2> .tmp/ecr-existing-error.txt; then
manifest="$(aws ecr batch-get-image \
--repository-name "$ECR_REPOSITORY_NAME" \
--image-ids "imageTag=$RELEASE_SHA" \
--query 'images[0].imageManifest' \
--output text)"
remote_config_digest="$(jq -er '.config.digest' <<< "$manifest")"
test "$remote_config_digest" = "$local_config_digest"
if aws ecr describe-images \
--repository-name "$ECR_REPOSITORY_NAME" \
--image-ids "imageTag=$VERSION" \
> .tmp/ecr-existing-version.json 2> .tmp/ecr-existing-version-error.txt; then
version_digest="$(jq -er '.imageDetails[0].imageDigest' .tmp/ecr-existing-version.json)"
else
if ! grep -q 'ImageNotFoundException' .tmp/ecr-existing-version-error.txt; then
echo "Unable to prove whether the immutable VERSION tag exists" >&2
exit 1
fi
aws ecr put-image \
--repository-name "$ECR_REPOSITORY_NAME" \
--image-tag "$VERSION" \
--image-manifest "$manifest" \
> /dev/null
version_digest="$(aws ecr describe-images \
--repository-name "$ECR_REPOSITORY_NAME" \
--image-ids "imageTag=$VERSION" \
--query 'imageDetails[0].imageDigest' \
--output text)"
fi
test "$version_digest" = "$(jq -er '.imageDetails[0].imageDigest' .tmp/ecr-existing.json)"
else
if ! grep -q 'ImageNotFoundException' .tmp/ecr-existing-error.txt; then
echo "Unable to prove whether the immutable release tag exists" >&2
exit 1
fi
aws ecr get-login-password | docker login \
--username AWS \
--password-stdin "${ECR_REPOSITORY_URI%%/*}"
docker image tag "dtc-website:$RELEASE_SHA" "$ECR_REPOSITORY_URI:$RELEASE_SHA"
docker image tag "dtc-website:$RELEASE_SHA" "$ECR_REPOSITORY_URI:$VERSION"
docker image push "$ECR_REPOSITORY_URI:$RELEASE_SHA"
docker image push "$ECR_REPOSITORY_URI:$VERSION"
fi
image_digest="$(aws ecr describe-images \
--repository-name "$ECR_REPOSITORY_NAME" \
--image-ids "imageTag=$RELEASE_SHA" \
--query 'imageDetails[0].imageDigest' \
--output text)"
if [[ ! "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "ECR did not resolve an immutable digest" >&2
exit 1
fi
manifest="$(aws ecr batch-get-image \
--repository-name "$ECR_REPOSITORY_NAME" \
--image-ids "imageDigest=$image_digest" \
--query 'images[0].imageManifest' \
--output text)"
remote_config_digest="$(jq -er '.config.digest' <<< "$manifest")"
test "$remote_config_digest" = "$local_config_digest"
sha_digest="$(aws ecr describe-images --repository-name "$ECR_REPOSITORY_NAME" --image-ids "imageTag=$RELEASE_SHA" --query 'imageDetails[0].imageDigest' --output text)"
version_digest="$(aws ecr describe-images --repository-name "$ECR_REPOSITORY_NAME" --image-ids "imageTag=$VERSION" --query 'imageDetails[0].imageDigest' --output text)"
test "$sha_digest" = "$image_digest"
test "$version_digest" = "$image_digest"
uv run --frozen python -m deploy.release_identity publish \
--source-identity .tmp/release/source-identity.json \
--repository-uri "$ECR_REPOSITORY_URI" \
--image-digest "$image_digest" \
--image-config-digest "$local_config_digest" \
--platform linux/amd64 \
--user 10001:10001 \
--output .tmp/published-image.json
- name: Verify and preserve the recorded immutable image without Docker
if: github.event_name == 'workflow_dispatch' && inputs.reuse_existing_image == true
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
VERSION: ${{ needs.resolve-release.outputs.version }}
IDENTITY_SCHEMA: ${{ needs.resolve-release.outputs.identity_schema }}
ECR_REPOSITORY_NAME: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_NAME }}
ECR_REPOSITORY_URI: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_URI }}
PUBLISHED_IMAGE_RECORD: ${{ inputs.published_image_record }}
run: |
set -euo pipefail
printf '%s\n' "$PUBLISHED_IMAGE_RECORD" > .tmp/input-published-image.json
uv run --frozen python -m deploy.release_identity inspect-published \
--allow-legacy --record .tmp/input-published-image.json \
> .tmp/published-image.json
recorded_digest="$(jq -er '.image_digest' .tmp/published-image.json)"
recorded_config_digest="$(jq -er '.image_config_digest' .tmp/published-image.json)"
test "$(jq -er '.source_sha' .tmp/published-image.json)" = "$RELEASE_SHA"
test "$(jq -er '.version' .tmp/published-image.json)" = "$VERSION"
test "$(jq -er '.identity_schema' .tmp/published-image.json)" = "$IDENTITY_SCHEMA"
test "$(jq -er '.repository_uri' .tmp/published-image.json)" = "$ECR_REPOSITORY_URI"
aws ecr describe-images \
--repository-name "$ECR_REPOSITORY_NAME" \
--image-ids "imageTag=$RELEASE_SHA" \
--output json > .tmp/reused-ecr-image.json
jq -e --arg digest "$recorded_digest" '
(.imageDetails | length) == 1 and
.imageDetails[0].imageDigest == $digest
' .tmp/reused-ecr-image.json > /dev/null
if [[ "$IDENTITY_SCHEMA" == "2" ]]; then
version_digest="$(aws ecr describe-images \
--repository-name "$ECR_REPOSITORY_NAME" \
--image-ids "imageTag=$VERSION" \
--query 'imageDetails[0].imageDigest' \
--output text)"
test "$version_digest" = "$recorded_digest"
fi
aws ecr batch-get-image \
--repository-name "$ECR_REPOSITORY_NAME" \
--image-ids "imageTag=$RELEASE_SHA" \
--output json > .tmp/reused-ecr-manifest.json
jq -e --arg digest "$recorded_digest" --arg config "$recorded_config_digest" '
(.failures | length) == 0 and
(.images | length) == 1 and
.images[0].imageId.imageDigest == $digest and
(.images[0].imageManifest | fromjson | .config.digest) == $config
' .tmp/reused-ecr-manifest.json > /dev/null
if [[ "$IDENTITY_SCHEMA" == "2" ]]; then
test "$(jq -er '.constructed_at' .tmp/published-image.json)" = \
"$(jq -er '.constructed_at' .tmp/release/source-identity.json)"
fi
- id: image
name: Export the one verified immutable digest
run: |
set -euo pipefail
image_digest="$(jq -er '.image_digest | select(test("^sha256:[0-9a-f]{64}$"))' .tmp/published-image.json)"
echo "image_digest=$image_digest" >> "$GITHUB_OUTPUT"
- name: Preserve the published-image record independently of deployment
uses: actions/upload-artifact@v4
with:
name: development-published-image-${{ needs.resolve-release.outputs.release_sha }}-attempt-${{ github.run_attempt }}
path: .tmp/published-image.json
if-no-files-found: error
retention-days: 90
deploy:
if: >-
always() &&
needs.ci-gate.result == 'success' &&
needs.publish.result == 'success' &&
((github.event_name == 'push' && vars.DEVELOPMENT_AUTO_DEPLOY == 'true' &&
needs.auto-capture-prior.result == 'success') ||
(github.event_name == 'workflow_dispatch' && inputs.deploy_development == true &&
inputs.operation != 'probe' && needs.auto-capture-prior.result == 'skipped')) &&
github.ref == 'refs/heads/main'
needs: [resolve-release, classification, quality, django, playwright, container, ci-gate, auto-capture-prior, publish]
runs-on: ubuntu-latest
environment:
name: sandbox
url: https://web.dtcdev.click
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- run: uv sync --locked
- run: uv lock --check
- run: uv run playwright install --with-deps chromium
- name: Validate deployer configuration before assuming AWS
env:
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
DEPLOYER_ROLE_ARN: ${{ vars.DEVELOPMENT_DEPLOYER_ROLE_ARN }}
ECR_REPOSITORY_URI: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_URI }}
ECS_CLUSTER_ARN: ${{ vars.DEVELOPMENT_ECS_CLUSTER_ARN }}
WEB_TARGET_GROUP_ARN: ${{ vars.DEVELOPMENT_WEB_TARGET_GROUP_ARN }}
WEB_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WEB_SERVICE_NAME }}
WORKER_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WORKER_SERVICE_NAME }}
WEB_FAMILY: ${{ vars.DEVELOPMENT_ECS_WEB_TASK_FAMILY }}
WORKER_FAMILY: ${{ vars.DEVELOPMENT_ECS_WORKER_TASK_FAMILY }}
MIGRATION_FAMILY: ${{ vars.DEVELOPMENT_ECS_MIGRATION_TASK_FAMILY }}
CONTAINER_NAMES: ${{ vars.DEVELOPMENT_ECS_CONTAINER_NAMES }}
TASK_ROLE_ARN: ${{ vars.DEVELOPMENT_ECS_TASK_ROLE_ARN }}
EXECUTION_ROLE_ARN: ${{ vars.DEVELOPMENT_ECS_EXECUTION_ROLE_ARN }}
ECS_SUBNET_IDS: ${{ vars.DEVELOPMENT_ECS_SUBNET_IDS }}
ECS_SECURITY_GROUP_IDS: ${{ vars.DEVELOPMENT_ECS_SECURITY_GROUP_IDS }}
ASSIGN_PUBLIC_IP: ${{ vars.DEVELOPMENT_ECS_ASSIGN_PUBLIC_IP }}
WEB_DESIRED_COUNT: ${{ vars.DEVELOPMENT_WEB_RELEASE_DESIRED_COUNT }}
WORKER_DESIRED_COUNT: ${{ vars.DEVELOPMENT_WORKER_RELEASE_DESIRED_COUNT }}
PROJECT_TAG: ${{ vars.DEVELOPMENT_RESOURCE_PROJECT_TAG }}
ENVIRONMENT_TAG: ${{ vars.DEVELOPMENT_RESOURCE_ENVIRONMENT_TAG }}
OPERATION: ${{ github.event_name == 'push' && 'promote' || inputs.operation }}
FAILURE_INJECTION: ${{ github.event_name == 'push' && 'none' || inputs.failure_injection }}
PRIOR_RELEASE_RECORD: ${{ inputs.prior_release_record }}
run: |
set -euo pipefail
uv run --frozen python -m deploy.legacy_development_compatibility deployer
case "$FAILURE_INJECTION" in
none|migration|post_mutation_smoke) ;;
*) echo "Unsupported release failure injection" >&2; exit 1 ;;
esac
if [[ "$FAILURE_INJECTION" != "none" ]]; then
test "$OPERATION" = "promote"
test -n "$PRIOR_RELEASE_RECORD"
fi
jq -e 'type == "array" and length == 2 and all(.[]; type == "string" and test("^subnet-[0-9a-f]+$"))' <<< "$ECS_SUBNET_IDS"
jq -e 'type == "array" and length == 1 and all(.[]; type == "string" and test("^sg-[0-9a-f]+$"))' <<< "$ECS_SECURITY_GROUP_IDS"
- name: Verify current-main controller immediately before deployer OIDC
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
controller="$(git rev-parse HEAD)"
test "$controller" = "$(git rev-parse refs/remotes/origin/main)"
if [[ "$EVENT_NAME" == "push" ]]; then
test "$controller" = "$RELEASE_SHA"
test "$GITHUB_SHA" = "$RELEASE_SHA"
fi
mkdir -p .tmp/deployment
date -u +%Y-%m-%dT%H:%M:%SZ > .tmp/deployment/current-main-before-oidc
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DEVELOPMENT_DEPLOYER_ROLE_ARN }}
aws-region: ${{ vars.DEVELOPMENT_AWS_REGION }}
role-session-name: website-deployer-${{ github.run_id }}
role-duration-seconds: 3600
- name: Recheck current-main controller before recovery checkpoint capture
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
controller="$(git rev-parse HEAD)"
test "$controller" = "$(git rev-parse refs/remotes/origin/main)"
if [[ "$EVENT_NAME" == "push" ]]; then
test "$controller" = "$RELEASE_SHA"
test "$GITHUB_SHA" = "$RELEASE_SHA"
fi
date -u +%Y-%m-%dT%H:%M:%SZ > .tmp/deployment/current-main-before-checkpoint
- name: Capture the exact pre-mutation recovery checkpoint
env:
OPERATION: ${{ github.event_name == 'push' && 'promote' || inputs.operation }}
ACTIVE_SERVICE_PAIR: ${{ needs.auto-capture-prior.outputs.active_service_pair }}
PRIOR_RELEASE_RECORD: ${{ inputs.prior_release_record }}
CURRENT_RELEASE_RECORD: ${{ inputs.current_release_record }}
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
REPOSITORY_URI: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_URI }}
CLUSTER_ARN: ${{ vars.DEVELOPMENT_ECS_CLUSTER_ARN }}
WEB_TARGET_GROUP_ARN: ${{ vars.DEVELOPMENT_WEB_TARGET_GROUP_ARN }}
WEB_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WEB_SERVICE_NAME }}
WORKER_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WORKER_SERVICE_NAME }}
WEB_FAMILY: ${{ vars.DEVELOPMENT_ECS_WEB_TASK_FAMILY }}
WORKER_FAMILY: ${{ vars.DEVELOPMENT_ECS_WORKER_TASK_FAMILY }}
MIGRATION_FAMILY: ${{ vars.DEVELOPMENT_ECS_MIGRATION_TASK_FAMILY }}
CONTAINER_NAMES: ${{ vars.DEVELOPMENT_ECS_CONTAINER_NAMES }}
TASK_ROLE_ARN: ${{ vars.DEVELOPMENT_ECS_TASK_ROLE_ARN }}
EXECUTION_ROLE_ARN: ${{ vars.DEVELOPMENT_ECS_EXECUTION_ROLE_ARN }}
SUBNET_IDS: ${{ vars.DEVELOPMENT_ECS_SUBNET_IDS }}
SECURITY_GROUP_IDS: ${{ vars.DEVELOPMENT_ECS_SECURITY_GROUP_IDS }}
ASSIGN_PUBLIC_IP: ${{ vars.DEVELOPMENT_ECS_ASSIGN_PUBLIC_IP }}
run: |
set -euo pipefail
mapfile -t subnet_ids < <(jq -er '.[]' <<< "$SUBNET_IDS")
mapfile -t security_group_ids < <(jq -er '.[]' <<< "$SECURITY_GROUP_IDS")
runtime_args=(
--region "$AWS_REGION"
--cluster-arn "$CLUSTER_ARN"
--web-target-group-arn "$WEB_TARGET_GROUP_ARN"
--web-service-name "$WEB_SERVICE_NAME"
--worker-service-name "$WORKER_SERVICE_NAME"
--web-family "$WEB_FAMILY"
--worker-family "$WORKER_FAMILY"
--migration-family "$MIGRATION_FAMILY"
--web-container-name "$(jq -er '.web' <<< "$CONTAINER_NAMES")"
--worker-container-name "$(jq -er '.worker' <<< "$CONTAINER_NAMES")"
--migration-container-name "$(jq -er '.migration' <<< "$CONTAINER_NAMES")"
--task-role-arn "$TASK_ROLE_ARN"
--execution-role-arn "$EXECUTION_ROLE_ARN"
--assign-public-ip "$ASSIGN_PUBLIC_IP"
--timeout-seconds 180
--web-stabilization-timeout-seconds 240
--worker-stabilization-timeout-seconds 420
--web-recovery-timeout-seconds 240
--worker-recovery-timeout-seconds 420
--recovery-phase-timeout-seconds 720
)
for subnet_id in "${subnet_ids[@]}"; do runtime_args+=(--subnet-id "$subnet_id"); done
for security_group_id in "${security_group_ids[@]}"; do
runtime_args+=(--security-group-id "$security_group_id")
done
expected_args=()
if [[ -n "$ACTIVE_SERVICE_PAIR" ]]; then
jq -ceS . <<< "$ACTIVE_SERVICE_PAIR" > .tmp/deployment/checkpoint-pair.json
expected_args=(--active-service-pair .tmp/deployment/checkpoint-pair.json)
elif [[ "$OPERATION" == "rollback" ]]; then
jq -ceS . <<< "$CURRENT_RELEASE_RECORD" > .tmp/deployment/checkpoint-prior.json
expected_args=(--prior-release-record .tmp/deployment/checkpoint-prior.json)
elif [[ -n "$PRIOR_RELEASE_RECORD" ]]; then
jq -ceS . <<< "$PRIOR_RELEASE_RECORD" > .tmp/deployment/checkpoint-prior.json
expected_args=(--prior-release-record .tmp/deployment/checkpoint-prior.json)
fi
uv run python -m deploy.cli capture-recovery \
"${runtime_args[@]}" \
--repository-uri "$REPOSITORY_URI" \
"${expected_args[@]}" \
--recovery-context-path .tmp/deployment/pre-mutation-recovery-context.json \
--evidence-path .tmp/deployment/controller-evidence.json
- name: Preserve the exact pre-mutation incident checkpoint
timeout-minutes: 2
uses: actions/upload-artifact@v4
with:
name: development-recovery-checkpoint-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/deployment/pre-mutation-recovery-context.json
if-no-files-found: error
retention-days: 90
- name: Recheck current-main controller immediately before release mutation
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
controller="$(git rev-parse HEAD)"
test "$controller" = "$(git rev-parse refs/remotes/origin/main)"
if [[ "$EVENT_NAME" == "push" ]]; then
test "$controller" = "$RELEASE_SHA"
test "$GITHUB_SHA" = "$RELEASE_SHA"
fi
date -u +%Y-%m-%dT%H:%M:%SZ > .tmp/deployment/current-main-before-mutation
- id: release
name: Promote or roll back one atomic web-and-worker release
env:
EVENT_NAME: ${{ github.event_name }}
OPERATION: ${{ github.event_name == 'push' && 'promote' || inputs.operation }}
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
VERSION: ${{ needs.resolve-release.outputs.version }}
IMAGE_DIGEST: ${{ needs.publish.outputs.image_digest }}
ACTIVE_SERVICE_PAIR: ${{ needs.auto-capture-prior.outputs.active_service_pair }}
PRIOR_RELEASE_RECORD: ${{ inputs.prior_release_record }}
TARGET_RELEASE_RECORD: ${{ inputs.target_release_record }}
CURRENT_RELEASE_RECORD: ${{ inputs.current_release_record }}
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
REPOSITORY_URI: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_URI }}
CLUSTER_ARN: ${{ vars.DEVELOPMENT_ECS_CLUSTER_ARN }}
WEB_TARGET_GROUP_ARN: ${{ vars.DEVELOPMENT_WEB_TARGET_GROUP_ARN }}
WEB_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WEB_SERVICE_NAME }}
WORKER_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WORKER_SERVICE_NAME }}
WEB_FAMILY: ${{ vars.DEVELOPMENT_ECS_WEB_TASK_FAMILY }}
WORKER_FAMILY: ${{ vars.DEVELOPMENT_ECS_WORKER_TASK_FAMILY }}
MIGRATION_FAMILY: ${{ vars.DEVELOPMENT_ECS_MIGRATION_TASK_FAMILY }}
CONTAINER_NAMES: ${{ vars.DEVELOPMENT_ECS_CONTAINER_NAMES }}
TASK_ROLE_ARN: ${{ vars.DEVELOPMENT_ECS_TASK_ROLE_ARN }}
EXECUTION_ROLE_ARN: ${{ vars.DEVELOPMENT_ECS_EXECUTION_ROLE_ARN }}
SUBNET_IDS: ${{ vars.DEVELOPMENT_ECS_SUBNET_IDS }}
SECURITY_GROUP_IDS: ${{ vars.DEVELOPMENT_ECS_SECURITY_GROUP_IDS }}
ASSIGN_PUBLIC_IP: ${{ vars.DEVELOPMENT_ECS_ASSIGN_PUBLIC_IP }}
WEB_DESIRED_COUNT: ${{ vars.DEVELOPMENT_WEB_RELEASE_DESIRED_COUNT }}
WORKER_DESIRED_COUNT: ${{ vars.DEVELOPMENT_WORKER_RELEASE_DESIRED_COUNT }}
PROJECT_TAG: ${{ vars.DEVELOPMENT_RESOURCE_PROJECT_TAG }}
ENVIRONMENT_TAG: ${{ vars.DEVELOPMENT_RESOURCE_ENVIRONMENT_TAG }}
FAILURE_INJECTION: ${{ github.event_name == 'push' && 'none' || inputs.failure_injection }}
DTC_TEST_SAFETY_COMMAND: remote_readonly
DTC_TEST_TARGET_CLASS: isolated_development
DTC_TEST_REMOTE_NAMESPACE: deploy-${{ github.run_id }}-${{ github.run_attempt }}
run: |
set -euo pipefail
mkdir -p .tmp/deployment .tmp/deployed-smoke
mapfile -t subnet_ids < <(jq -er '.[]' <<< "$SUBNET_IDS")
mapfile -t security_group_ids < <(jq -er '.[]' <<< "$SECURITY_GROUP_IDS")
web_container="$(jq -er '.web' <<< "$CONTAINER_NAMES")"
worker_container="$(jq -er '.worker' <<< "$CONTAINER_NAMES")"
migration_container="$(jq -er '.migration' <<< "$CONTAINER_NAMES")"
runtime_args=(
--region "$AWS_REGION"
--cluster-arn "$CLUSTER_ARN"
--web-target-group-arn "$WEB_TARGET_GROUP_ARN"
--web-service-name "$WEB_SERVICE_NAME"
--worker-service-name "$WORKER_SERVICE_NAME"
--web-family "$WEB_FAMILY"
--worker-family "$WORKER_FAMILY"
--migration-family "$MIGRATION_FAMILY"
--web-container-name "$web_container"
--worker-container-name "$worker_container"
--migration-container-name "$migration_container"
--task-role-arn "$TASK_ROLE_ARN"
--execution-role-arn "$EXECUTION_ROLE_ARN"
--assign-public-ip "$ASSIGN_PUBLIC_IP"
--base-url https://web.dtcdev.click
--screenshot-directory .tmp/deployed-smoke
--timeout-seconds 180
--web-stabilization-timeout-seconds 240
--worker-stabilization-timeout-seconds 420
--web-recovery-timeout-seconds 240
--worker-recovery-timeout-seconds 420
--recovery-phase-timeout-seconds 720
)
for subnet_id in "${subnet_ids[@]}"; do runtime_args+=(--subnet-id "$subnet_id"); done
for security_group_id in "${security_group_ids[@]}"; do
runtime_args+=(--security-group-id "$security_group_id")
done
declare -p runtime_args > .tmp/deployment/runtime-args.sh
if [[ "$OPERATION" == "promote" ]]; then
prior_args=()
if [[ "$EVENT_NAME" == "push" ]]; then
test -n "$ACTIVE_SERVICE_PAIR"
jq -ceS . <<< "$ACTIVE_SERVICE_PAIR" > .tmp/deployment/active-service-pair.json
prior_args=(--active-service-pair .tmp/deployment/active-service-pair.json)
elif [[ -n "$PRIOR_RELEASE_RECORD" ]]; then
jq -ceS . <<< "$PRIOR_RELEASE_RECORD" > .tmp/deployment/prior.json
prior_args=(--prior-release-record .tmp/deployment/prior.json)
fi
uv run python -m deploy.cli promote \
"${runtime_args[@]}" \
--source-sha "$RELEASE_SHA" \
--version "$VERSION" \
--image-digest "$IMAGE_DIGEST" \
--repository-uri "$REPOSITORY_URI" \
--web-desired-count "$WEB_DESIRED_COUNT" \
--worker-desired-count "$WORKER_DESIRED_COUNT" \
--project-tag "$PROJECT_TAG" \
--environment-tag "$ENVIRONMENT_TAG" \
--failure-injection "$FAILURE_INJECTION" \
"${prior_args[@]}" \
--evidence-path .tmp/deployment/controller-evidence.json \
--recovery-context-path .tmp/deployment/recovery-context.json \
--release-record-path .tmp/deployment/successful-release.json
else
test -n "$TARGET_RELEASE_RECORD"
test -n "$CURRENT_RELEASE_RECORD"
jq -ceS . <<< "$TARGET_RELEASE_RECORD" > .tmp/deployment/target.json
target_identity="$(uv run python -c \
'from pathlib import Path; from deploy.contracts import ReleaseRecord; r = ReleaseRecord.read(Path(".tmp/deployment/target.json")); print(" ".join((r.version, r.source_sha, r.image_digest)))')"
test "$target_identity" = "$VERSION $RELEASE_SHA $IMAGE_DIGEST"
jq -ceS . <<< "$CURRENT_RELEASE_RECORD" > .tmp/deployment/current.json
uv run python -m deploy.cli rollback \
"${runtime_args[@]}" \
--repository-uri "$REPOSITORY_URI" \
--target-release-record .tmp/deployment/target.json \
--current-release-record .tmp/deployment/current.json \
--evidence-path .tmp/deployment/controller-evidence.json \
--recovery-context-path .tmp/deployment/recovery-context.json \
--release-record-path .tmp/deployment/successful-release.json
fi
- id: evidence_builder
name: Build redacted deployment evidence on success or failure
if: always()
continue-on-error: true
timeout-minutes: 1
env:
ACTIVE_SERVICE_PAIR: ${{ needs.auto-capture-prior.outputs.active_service_pair }}
CURRENT_RELEASE_RECORD: ${{ inputs.current_release_record }}
CONTROLLER_OUTCOME: ${{ steps.release.outcome }}
EVENT_NAME: ${{ github.event_name }}
IMAGE_DIGEST: ${{ needs.publish.outputs.image_digest }}
VERSION: ${{ needs.resolve-release.outputs.version }}
IDENTITY_SCHEMA: ${{ needs.resolve-release.outputs.identity_schema }}
PRIOR_RELEASE_RECORD: ${{ inputs.prior_release_record }}
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GATE_RESOLVE: ${{ needs.resolve-release.result }}
GATE_QUALITY: ${{ needs.quality.result }}
GATE_DJANGO: ${{ needs.django.result }}
GATE_PLAYWRIGHT: ${{ needs.playwright.result }}
GATE_CONTAINER: ${{ needs.container.result }}
GATE_CAPTURE: ${{ needs.auto-capture-prior.result }}
GATE_PUBLISH: ${{ needs.publish.result }}
CAPTURE_BEFORE_OIDC_AT: ${{ needs.auto-capture-prior.outputs.before_oidc_at }}
CAPTURE_BEFORE_READ_AT: ${{ needs.auto-capture-prior.outputs.before_capture_at }}
PUBLISH_BEFORE_OIDC_AT: ${{ needs.publish.outputs.before_oidc_at }}
PUBLISH_BEFORE_WRITE_AT: ${{ needs.publish.outputs.before_publish_at }}
run: |
set -euo pipefail
mkdir -p .tmp/deployment
uv run --frozen python - <<'PY'
import json
import os
from pathlib import Path
directory = Path(".tmp/deployment")
successful_path = directory / "successful-release.json"
successful = (
os.environ.get("CONTROLLER_OUTCOME") == "success"
and successful_path.exists()
)
prior_text = (
os.environ.get("ACTIVE_SERVICE_PAIR")
or os.environ.get("CURRENT_RELEASE_RECORD")
or os.environ.get("PRIOR_RELEASE_RECORD")
)
prior = json.loads(prior_text) if prior_text else None
released = json.loads(successful_path.read_text()) if successful else None
controller_path = directory / "controller-evidence.json"
controller = json.loads(controller_path.read_text()) if controller_path.exists() else {"stages": []}
http_path = Path(".tmp/deployed-smoke/http-evidence.json")
http_evidence = json.loads(http_path.read_text()) if http_path.exists() else None
def marker(name):
path = directory / name
return path.read_text().strip() if path.exists() else ""
current_main_checks = [
{"checkpoint": "capture_before_oidc", "result": "passed", "timestamp": os.environ.get("CAPTURE_BEFORE_OIDC_AT")}
if os.environ.get("CAPTURE_BEFORE_OIDC_AT") else
{"checkpoint": "capture_before_oidc", "result": "not_applicable", "timestamp": None},
{"checkpoint": "capture_before_read", "result": "passed", "timestamp": os.environ.get("CAPTURE_BEFORE_READ_AT")}
if os.environ.get("CAPTURE_BEFORE_READ_AT") else
{"checkpoint": "capture_before_read", "result": "not_applicable", "timestamp": None},
{"checkpoint": "publisher_before_oidc", "result": "passed" if os.environ.get("PUBLISH_BEFORE_OIDC_AT") else "not_proven", "timestamp": os.environ.get("PUBLISH_BEFORE_OIDC_AT") or None},
{"checkpoint": "publisher_before_write", "result": "passed" if os.environ.get("PUBLISH_BEFORE_WRITE_AT") else "not_proven", "timestamp": os.environ.get("PUBLISH_BEFORE_WRITE_AT") or None},
{"checkpoint": "deployer_before_oidc", "result": "passed" if marker("current-main-before-oidc") else "not_proven", "timestamp": marker("current-main-before-oidc") or None},
{"checkpoint": "deployer_before_checkpoint", "result": "passed" if marker("current-main-before-checkpoint") else "not_proven", "timestamp": marker("current-main-before-checkpoint") or None},
{"checkpoint": "deployer_before_mutation", "result": "passed" if marker("current-main-before-mutation") else "not_proven", "timestamp": marker("current-main-before-mutation") or None},
]
gate_results = {
"source_resolution": os.environ["GATE_RESOLVE"],
"quality": os.environ["GATE_QUALITY"],
"deployment_contract": os.environ["GATE_QUALITY"],
"django_sqlite": os.environ["GATE_DJANGO"],
"playwright": os.environ["GATE_PLAYWRIGHT"],
"container": os.environ["GATE_CONTAINER"],
"automatic_prior_capture": os.environ["GATE_CAPTURE"],
"publisher": os.environ["GATE_PUBLISH"],
}
evidence = {
"run_id": os.environ["RUN_ID"],
"run_attempt": os.environ["RUN_ATTEMPT"],
"run_url": os.environ["RUN_URL"],
"event": os.environ["EVENT_NAME"],
"controller_sha": os.environ.get("GITHUB_SHA", ""),
"source_sha": os.environ.get("RELEASE_SHA", ""),
"version": os.environ.get("VERSION", ""),
"identity_schema": int(os.environ.get("IDENTITY_SCHEMA", "0")),
"image_digest": os.environ.get("IMAGE_DIGEST", ""),
"captured_prior": prior,
"released": released,
"gate_results": gate_results,
"current_main_checks": current_main_checks,
"http_smoke": http_evidence,
"result": (
"controller_succeeded_pending_artifact_finalization"
if successful else
"failed_without_success_record"
),
"stages": controller.get("stages", []),
}
(directory / "deployment-evidence.json").write_text(
json.dumps(evidence, indent=2, sort_keys=True) + "\n"
)
PY
- id: evidence_upload
name: Preserve redacted deployment evidence even for a failed release
if: always() && steps.evidence_builder.outcome == 'success'
continue-on-error: true
timeout-minutes: 2
uses: actions/upload-artifact@v4
with:
name: development-deployment-evidence-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/deployment/deployment-evidence.json
if-no-files-found: error
retention-days: 90
- id: smoke_upload
name: Preserve read-only browser and HTTP evidence
if: always() && steps.release.outcome == 'success'
continue-on-error: true
timeout-minutes: 2
uses: actions/upload-artifact@v4
with:
name: development-read-only-smoke-${{ needs.resolve-release.outputs.release_sha }}-attempt-${{ github.run_attempt }}
path: .tmp/deployed-smoke
if-no-files-found: error
retention-days: 30
- id: success_record_upload
name: Preserve only the successful rollback-eligible release record
if: >-
always() && steps.release.outcome == 'success' &&
steps.evidence_builder.outcome == 'success' &&
steps.evidence_upload.outcome == 'success' &&
steps.smoke_upload.outcome == 'success'
continue-on-error: true
timeout-minutes: 2
uses: actions/upload-artifact@v4
with:
name: development-successful-release-${{ needs.resolve-release.outputs.release_sha }}-attempt-${{ github.run_attempt }}
path: .tmp/deployment/successful-release.json
if-no-files-found: error
retention-days: 90
- id: finalization_recovery
name: Restore the exact prior pair when artifact finalization fails
if: >-
always() && steps.release.outcome == 'success' &&
(steps.evidence_builder.outcome != 'success' ||
steps.evidence_upload.outcome != 'success' ||
steps.smoke_upload.outcome != 'success' ||
steps.success_record_upload.outcome != 'success')
env:
FINALIZATION_EVIDENCE: ${{ steps.evidence_upload.outcome }}
FINALIZATION_SMOKE: ${{ steps.smoke_upload.outcome }}
FINALIZATION_RECORD: ${{ steps.success_record_upload.outcome }}
timeout-minutes: 12
run: |
set -euo pipefail
source .tmp/deployment/runtime-args.sh
restore_status=0
uv run python -m deploy.cli restore-finalization \
"${runtime_args[@]}" \
--recovery-context .tmp/deployment/recovery-context.json \
--failed-release-record .tmp/deployment/successful-release.json \
--evidence-path .tmp/deployment/deployment-evidence.json || restore_status=$?
RESTORE_STATUS="$restore_status" uv run --frozen python - <<'PY'
import datetime
import json
import os
from pathlib import Path
path = Path(".tmp/deployment/deployment-evidence.json")
payload = json.loads(path.read_text()) if path.exists() else {"stages": []}
restored = os.environ["RESTORE_STATUS"] == "0"
payload["result"] = (
"artifact_finalization_failed_compensated"
if restored else
"artifact_finalization_failed_recovery_failed"
)
payload["released"] = None
payload.setdefault("stages", []).append({
"stage": "artifact_finalization",
"result": "failed",
"timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
"proof": {
"evidence_upload": os.environ["FINALIZATION_EVIDENCE"],
"smoke_upload": os.environ["FINALIZATION_SMOKE"],
"release_record_upload": os.environ["FINALIZATION_RECORD"],
"exact_pair_recovery": "passed" if restored else "failed",
},
})
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
PY
if [[ "$restore_status" == "0" ]]; then
rm .tmp/deployment/successful-release.json
fi
exit "$restore_status"
- name: Preserve finalization-failure recovery evidence
if: always() && steps.finalization_recovery.outcome != 'skipped'
continue-on-error: true
timeout-minutes: 2
uses: actions/upload-artifact@v4
with:
name: development-deployment-finalization-failure-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: .tmp/deployment/deployment-evidence.json
if-no-files-found: error
retention-days: 90
- name: Fail loudly after artifact finalization recovery
if: always() && steps.finalization_recovery.outcome != 'skipped'
run: |
echo "Release artifacts did not finalize; exact-pair recovery was required." >&2
exit 1
probe-contract:
if: >-
github.event_name == 'workflow_dispatch' &&
inputs.operation == 'probe' &&
inputs.probe_development == true &&
inputs.deploy_development == false &&
github.ref == 'refs/heads/main'
needs: resolve-release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- run: uv sync --locked
- run: uv lock --check
- run: uv run ruff check deploy core/tests/test_deployment_*.py
- run: uv run ruff format --check deploy core/tests/test_deployment_*.py
- name: Verify probe and release-controller contracts
env:
DJANGO_SETTINGS_MODULE: website.settings.test
run: >-
uv run python manage.py test
core.tests.test_deployment_oidc_probe
core.tests.test_deployment_release
core.tests.test_deployment_workflow
probe-publisher:
if: >-
github.event_name == 'workflow_dispatch' &&
inputs.operation == 'probe' &&
inputs.probe_development == true &&
inputs.deploy_development == false &&
github.ref == 'refs/heads/main'
needs: [resolve-release, probe-contract]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- run: uv sync --locked
- name: Validate exact publisher probe inputs
env:
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
ECR_REPOSITORY_NAME: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_NAME }}
HOSTED_ZONE_ID: ${{ vars.DEVELOPMENT_ROUTE53_HOSTED_ZONE_ID }}
KMS_KEY_ARN: ${{ vars.DEVELOPMENT_KMS_KEY_ARN }}
PUBLISHER_ROLE_ARN: ${{ vars.DEVELOPMENT_PUBLISHER_ROLE_ARN }}
run: |
set -euo pipefail
uv run --frozen python -m deploy.legacy_development_compatibility publisher-probe
- name: Verify current-main controller immediately before publisher probe OIDC
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
controller="$(git rev-parse HEAD)"
test "$controller" = "$(git rev-parse refs/remotes/origin/main)"
test "$RELEASE_SHA" = "$controller"
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DEVELOPMENT_PUBLISHER_ROLE_ARN }}
aws-region: ${{ vars.DEVELOPMENT_AWS_REGION }}
role-session-name: website-publisher-probe-${{ github.run_id }}
- name: Probe publisher metadata and denied boundaries
env:
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
ECR_REPOSITORY_NAME: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_NAME }}
HOSTED_ZONE_ID: ${{ vars.DEVELOPMENT_ROUTE53_HOSTED_ZONE_ID }}
KMS_KEY_ARN: ${{ vars.DEVELOPMENT_KMS_KEY_ARN }}
run: |
set -euo pipefail
uv run --frozen python -m deploy.development_oidc_probe publisher \
--account-id 817685572750 \
--region "$AWS_REGION" \
--repository-name "$ECR_REPOSITORY_NAME" \
--hosted-zone-id "$HOSTED_ZONE_ID" \
--kms-key-arn "$KMS_KEY_ARN" \
--probe-id "$GITHUB_RUN_ID"
probe-deployer:
if: >-
github.event_name == 'workflow_dispatch' &&
inputs.operation == 'probe' &&
inputs.probe_development == true &&
inputs.deploy_development == false &&
github.ref == 'refs/heads/main'
needs: [resolve-release, probe-contract]
runs-on: ubuntu-latest
environment:
name: sandbox
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- run: uv sync --locked
- name: Validate exact deployer probe inputs
env:
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
DEPLOYER_ROLE_ARN: ${{ vars.DEVELOPMENT_DEPLOYER_ROLE_ARN }}
ECR_REPOSITORY_NAME: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_NAME }}
ECS_CLUSTER_ARN: ${{ vars.DEVELOPMENT_ECS_CLUSTER_ARN }}
HOSTED_ZONE_ID: ${{ vars.DEVELOPMENT_ROUTE53_HOSTED_ZONE_ID }}
KMS_KEY_ARN: ${{ vars.DEVELOPMENT_KMS_KEY_ARN }}
WEB_TARGET_GROUP_ARN: ${{ vars.DEVELOPMENT_WEB_TARGET_GROUP_ARN }}
WEB_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WEB_SERVICE_NAME }}
WORKER_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WORKER_SERVICE_NAME }}
WEB_FAMILY: ${{ vars.DEVELOPMENT_ECS_WEB_TASK_FAMILY }}
WORKER_FAMILY: ${{ vars.DEVELOPMENT_ECS_WORKER_TASK_FAMILY }}
MIGRATION_FAMILY: ${{ vars.DEVELOPMENT_ECS_MIGRATION_TASK_FAMILY }}
run: |
set -euo pipefail
uv run --frozen python -m deploy.legacy_development_compatibility deployer-probe
- name: Verify current-main controller immediately before deployer probe OIDC
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
controller="$(git rev-parse HEAD)"
test "$controller" = "$(git rev-parse refs/remotes/origin/main)"
test "$RELEASE_SHA" = "$controller"
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.DEVELOPMENT_DEPLOYER_ROLE_ARN }}
aws-region: ${{ vars.DEVELOPMENT_AWS_REGION }}
role-session-name: website-deployer-probe-${{ github.run_id }}
- name: Probe deployer metadata and denied boundaries
env:
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
ECR_REPOSITORY_NAME: ${{ vars.DEVELOPMENT_ECR_REPOSITORY_NAME }}
ECS_CLUSTER_ARN: ${{ vars.DEVELOPMENT_ECS_CLUSTER_ARN }}
HOSTED_ZONE_ID: ${{ vars.DEVELOPMENT_ROUTE53_HOSTED_ZONE_ID }}
KMS_KEY_ARN: ${{ vars.DEVELOPMENT_KMS_KEY_ARN }}
WEB_TARGET_GROUP_ARN: ${{ vars.DEVELOPMENT_WEB_TARGET_GROUP_ARN }}
WEB_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WEB_SERVICE_NAME }}
WORKER_SERVICE_NAME: ${{ vars.DEVELOPMENT_ECS_WORKER_SERVICE_NAME }}
WEB_FAMILY: ${{ vars.DEVELOPMENT_ECS_WEB_TASK_FAMILY }}
WORKER_FAMILY: ${{ vars.DEVELOPMENT_ECS_WORKER_TASK_FAMILY }}
MIGRATION_FAMILY: ${{ vars.DEVELOPMENT_ECS_MIGRATION_TASK_FAMILY }}
run: |
set -euo pipefail
uv run --frozen python -m deploy.development_oidc_probe deployer \
--account-id 817685572750 \
--region "$AWS_REGION" \
--repository-name "$ECR_REPOSITORY_NAME" \
--hosted-zone-id "$HOSTED_ZONE_ID" \
--kms-key-arn "$KMS_KEY_ARN" \
--cluster-arn "$ECS_CLUSTER_ARN" \
--web-target-group-arn "$WEB_TARGET_GROUP_ARN" \
--web-service-name "$WEB_SERVICE_NAME" \
--worker-service-name "$WORKER_SERVICE_NAME" \
--task-family "$WEB_FAMILY" \
--task-family "$WORKER_FAMILY" \
--task-family "$MIGRATION_FAMILY" \
--probe-id "$GITHUB_RUN_ID"
probe-wrong-main-claims:
if: >-
github.event_name == 'workflow_dispatch' &&
inputs.operation == 'probe' &&
inputs.probe_development == true &&
inputs.deploy_development == false &&
github.ref == 'refs/heads/main'
needs: [resolve-release, probe-contract]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
env:
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
AWS_EC2_METADATA_DISABLED: true
PUBLISHER_ROLE_ARN: ${{ vars.DEVELOPMENT_PUBLISHER_ROLE_ARN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- run: uv sync --locked
- name: Select and validate exact wrong-claim probe inputs
run: |
set -euo pipefail
deployer_role="$(uv run --frozen python -c 'from deploy.legacy_development_compatibility import DEPLOYER_ROLE_ARN; print(DEPLOYER_ROLE_ARN)')"
DEPLOYER_ROLE_ARN="$deployer_role" uv run --frozen python -m deploy.legacy_development_compatibility main-claim-probe
printf 'DEPLOYER_ROLE_ARN=%s\n' "$deployer_role" >> "$GITHUB_ENV"
- name: Verify current-main controller immediately before main-claim token request
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
controller="$(git rev-parse HEAD)"
test "$controller" = "$(git rev-parse refs/remotes/origin/main)"
test "$RELEASE_SHA" = "$controller"
- name: Require main-ref subject denial for the environment role
run: >-
uv run --frozen python -m deploy.development_oidc_claim_probe
--role-arn "$DEPLOYER_ROLE_ARN"
--audience sts.amazonaws.com
--claim-label main-subject-to-environment-role
--probe-id "$GITHUB_RUN_ID"
- name: Verify current-main controller immediately before wrong-audience token request
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
controller="$(git rev-parse HEAD)"
test "$controller" = "$(git rev-parse refs/remotes/origin/main)"
test "$RELEASE_SHA" = "$controller"
- name: Require wrong-audience denial for the publisher role
run: >-
uv run --frozen python -m deploy.development_oidc_claim_probe
--role-arn "$PUBLISHER_ROLE_ARN"
--audience dtc.invalid.example
--claim-label wrong-audience-to-main-role
--probe-id "$GITHUB_RUN_ID"
probe-wrong-environment-claim:
if: >-
github.event_name == 'workflow_dispatch' &&
inputs.operation == 'probe' &&
inputs.probe_development == true &&
inputs.deploy_development == false &&
github.ref == 'refs/heads/main'
needs: [resolve-release, probe-contract]
runs-on: ubuntu-latest
environment:
name: sandbox
permissions:
contents: read
id-token: write
env:
AWS_REGION: ${{ vars.DEVELOPMENT_AWS_REGION }}
AWS_EC2_METADATA_DISABLED: true
PUBLISHER_ROLE_ARN: ${{ vars.DEVELOPMENT_PUBLISHER_ROLE_ARN }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v6
with:
version: "0.10.11"
enable-cache: true
- run: uv sync --locked
- name: Validate exact environment-claim probe inputs
run: |
set -euo pipefail
uv run --frozen python -m deploy.legacy_development_compatibility environment-claim-probe
- name: Verify current-main controller immediately before environment-claim token request
env:
RELEASE_SHA: ${{ needs.resolve-release.outputs.release_sha }}
run: |
set -euo pipefail
git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main
controller="$(git rev-parse HEAD)"
test "$controller" = "$(git rev-parse refs/remotes/origin/main)"
test "$RELEASE_SHA" = "$controller"
- name: Require environment-subject denial for the main-ref role
run: >-
uv run --frozen python -m deploy.development_oidc_claim_probe
--role-arn "$PUBLISHER_ROLE_ARN"
--audience sts.amazonaws.com
--claim-label environment-subject-to-main-role
--probe-id "$GITHUB_RUN_ID"