Merge issue-162-podcast-canonicals: Restore .html podcast canonical r… #121
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| 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 | |
| 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: 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 | |
| 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 | |
| ;; | |
| *) | |
| 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 | |
| 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 | |
| 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 | |
| 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" | |
| 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 | |
| - 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" |