Merge issue #102: bound compensation recovery deadlines #40
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 | |
| concurrency: | |
| group: website-development-release | |
| cancel-in-progress: false | |
| jobs: | |
| resolve-release: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| release_sha: ${{ steps.release.outputs.release_sha }} | |
| 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: release | |
| name: Validate immutable source selection | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| 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: | | |
| 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 | |
| jq -e \ | |
| --arg source_sha "$candidate" \ | |
| --arg repository_uri "$DEVELOPMENT_REPOSITORY_URI" ' | |
| type == "object" and | |
| keys == ["image_config_digest", "image_digest", "platform", "repository_uri", "source_sha", "user"] and | |
| .source_sha == $source_sha and | |
| .repository_uri == $repository_uri and | |
| (.image_digest | type == "string" and test("^sha256:[0-9a-f]{64}$")) and | |
| .image_digest != "sha256:1111111111111111111111111111111111111111111111111111111111111111" and | |
| .image_digest != "sha256:0000000000000000000000000000000000000000000000000000000000000000" and | |
| (.image_config_digest | type == "string" and test("^sha256:[0-9a-f]{64}$")) and | |
| .platform == "linux/amd64" and | |
| .user == "10001:10001" | |
| ' <<< "$INPUT_PUBLISHED_IMAGE_RECORD" > /dev/null | |
| 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" | |
| 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 | |
| echo "release_sha=$candidate" >> "$GITHUB_OUTPUT" | |
| quality: | |
| if: github.event_name != 'workflow_dispatch' || inputs.operation != 'probe' | |
| needs: resolve-release | |
| 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: astral-sh/setup-uv@v6 | |
| with: | |
| version: "0.10.11" | |
| enable-cache: true | |
| - run: uv sync --locked | |
| - run: uv lock --check | |
| - run: make terminology-check | |
| - run: make lint | |
| - run: make format-check | |
| - run: make typecheck | |
| - run: make migrations-check | |
| - run: make django-check | |
| - run: make deployment-check | |
| django: | |
| if: github.event_name != 'workflow_dispatch' || inputs.operation != 'probe' | |
| needs: resolve-release | |
| runs-on: ubuntu-latest | |
| services: | |
| postgres: | |
| image: postgres:16 | |
| env: | |
| POSTGRES_DB: dtc_test | |
| POSTGRES_USER: dtc | |
| POSTGRES_PASSWORD: dtc-test-only | |
| ports: ["5432:5432"] | |
| options: >- | |
| --health-cmd "pg_isready -U dtc -d dtc_test" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| env: | |
| DJANGO_SETTINGS_MODULE: website.settings.test | |
| DATABASE_URL: postgresql://dtc:dtc-test-only@127.0.0.1:5432/dtc_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: astral-sh/setup-uv@v6 | |
| with: | |
| version: "0.10.11" | |
| enable-cache: true | |
| - run: uv sync --locked | |
| - run: uv lock --check | |
| - run: uv run python manage.py migrate --noinput | |
| - run: make test | |
| playwright: | |
| if: github.event_name != 'workflow_dispatch' || inputs.operation != 'probe' | |
| needs: resolve-release | |
| 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: 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 | |
| - run: make test-playwright-core | |
| container: | |
| if: github.event_name != 'workflow_dispatch' || inputs.operation != 'probe' | |
| needs: resolve-release | |
| runs-on: ubuntu-latest | |
| steps: | |
| - 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: test -n "$PUBLISHED_IMAGE_RECORD" | |
| - 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 }} | |
| run: | | |
| set -euo pipefail | |
| docker buildx build \ | |
| --platform linux/amd64 \ | |
| --provenance=false \ | |
| --label "org.opencontainers.image.revision=$RELEASE_SHA" \ | |
| --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 }} | |
| 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 run --rm --entrypoint id "$image" -u)" = "10001" | |
| test "$(docker run --rm --entrypoint id "$image" -g)" = "10001" | |
| - 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 | |
| if verify_manifest website.settings.test; then | |
| echo "Static verification accepted an incompatible storage backend." >&2 | |
| exit 1 | |
| fi | |
| - 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 }} | |
| run: | | |
| set -euo pipefail | |
| docker run --detach --name dtc-web \ | |
| --env DJANGO_SETTINGS_MODULE=website.settings.local \ | |
| --env DTC_USE_SQLITE=1 \ | |
| --env "APP_VERSION=$RELEASE_SHA" \ | |
| --publish 8000:8000 "dtc-website:$RELEASE_SHA" web | |
| trap 'docker logs dtc-web; docker rm --force dtc-web' EXIT | |
| for attempt in $(seq 1 30); do | |
| if test "$(curl --fail --silent http://127.0.0.1:8000/health/live)" = "{\"status\": \"ok\", \"version\": \"$RELEASE_SHA\"}"; then | |
| curl --fail --silent --output /dev/null http://127.0.0.1:8000/unified/ | |
| 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 | |
| auto-capture-prior: | |
| if: >- | |
| github.event_name == 'push' && | |
| vars.DEVELOPMENT_AUTO_DEPLOY == 'true' && | |
| github.ref == 'refs/heads/main' | |
| needs: [resolve-release, quality, django, playwright, container] | |
| 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.resolve-release.result == 'success' && | |
| needs.quality.result == 'success' && | |
| needs.django.result == 'success' && | |
| needs.playwright.result == 'success' && | |
| needs.container.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, quality, django, playwright, container, 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 | |
| - 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 }} | |
| 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" | |
| [[ "$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" | |
| 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 push "$ECR_REPOSITORY_URI:$RELEASE_SHA" | |
| 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" | |
| jq -cn \ | |
| --arg source_sha "$RELEASE_SHA" \ | |
| --arg repository_uri "$ECR_REPOSITORY_URI" \ | |
| --arg image_digest "$image_digest" \ | |
| --arg image_config_digest "$local_config_digest" \ | |
| '{source_sha: $source_sha, repository_uri: $repository_uri, image_digest: $image_digest, image_config_digest: $image_config_digest, platform: "linux/amd64", user: "10001:10001"}' \ | |
| > .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 }} | |
| 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 | |
| jq -ceS . <<< "$PUBLISHED_IMAGE_RECORD" > .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 '.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 | |
| 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 | |
| - 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.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, quality, django, playwright, container, 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 }} | |
| 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 }} | |
| 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" \ | |
| --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 --arg sha "$RELEASE_SHA" --arg digest "$IMAGE_DIGEST" \ | |
| 'select(.source_sha == $sha and .image_digest == $digest)' \ | |
| <<< "$TARGET_RELEASE_RECORD" > .tmp/deployment/target.json | |
| 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 }} | |
| 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_postgresql": 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", ""), | |
| "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" |