7979 name : Validate immutable source selection
8080 env :
8181 EVENT_NAME : ${{ github.event_name }}
82+ EVENT_AFTER : ${{ github.event.after }}
8283 INPUT_RELEASE_SHA : ${{ inputs.release_sha }}
8384 INPUT_DEPLOY_DEVELOPMENT : ${{ inputs.deploy_development }}
8485 INPUT_PROBE_DEVELOPMENT : ${{ inputs.probe_development }}
@@ -238,6 +239,10 @@ jobs:
238239 fi
239240 else
240241 candidate="$GITHUB_SHA"
242+ if [[ "$EVENT_AFTER" != "$candidate" ]]; then
243+ echo "Push after SHA does not match the workflow source" >&2
244+ exit 1
245+ fi
241246 fi
242247 if [[ ! "$candidate" =~ ^[0-9a-f]{40}$ ]]; then
243248 echo "Release SHA must be exactly 40 lowercase hexadecimal characters" >&2
@@ -266,6 +271,58 @@ jobs:
266271 fi
267272 echo "release_sha=$candidate" >> "$GITHUB_OUTPUT"
268273
274+ classification :
275+ if : github.event_name != 'workflow_dispatch' || inputs.operation != 'probe'
276+ needs : resolve-release
277+ runs-on : ubuntu-latest
278+ outputs :
279+ profile : ${{ steps.selection.outputs.profile }}
280+ reason : ${{ steps.selection.outputs.reason }}
281+ defaults :
282+ run :
283+ working-directory : .tmp/ci-controller
284+ steps :
285+ - uses : actions/checkout@v4
286+ with :
287+ ref : ${{ github.sha }}
288+ path : .tmp/ci-controller
289+ - uses : actions/checkout@v4
290+ with :
291+ ref : ${{ needs.resolve-release.outputs.release_sha }}
292+ path : .tmp/release-source
293+ fetch-depth : 0
294+ - uses : astral-sh/setup-uv@v6
295+ with :
296+ version : " 0.10.11"
297+ enable-cache : true
298+ - id : selection
299+ name : Classify the complete source change safely
300+ env :
301+ EVENT_NAME : ${{ github.event_name }}
302+ EVENT_BEFORE : ${{ github.event.before }}
303+ EVENT_AFTER : ${{ github.event.after }}
304+ RELEASE_SHA : ${{ needs.resolve-release.outputs.release_sha }}
305+ run : |
306+ set -euo pipefail
307+ mkdir -p .tmp
308+ uv run --frozen python -m ci.classifier select \
309+ --repository ../release-source \
310+ --event "$EVENT_NAME" \
311+ --base "$EVENT_BEFORE" \
312+ --after "$EVENT_AFTER" \
313+ --github-sha "$GITHUB_SHA" \
314+ --release-sha "$RELEASE_SHA" \
315+ --output .tmp/ci-selection.json \
316+ --summary "$GITHUB_STEP_SUMMARY" \
317+ --github-output "$GITHUB_OUTPUT"
318+ - name : Preserve the machine-readable CI selection
319+ uses : actions/upload-artifact@v4
320+ with :
321+ name : ci-selection-${{ github.run_id }}-attempt-${{ github.run_attempt }}
322+ path : .tmp/ci-controller/.tmp/ci-selection.json
323+ if-no-files-found : error
324+ retention-days : 30
325+
269326 quality :
270327 if : github.event_name != 'workflow_dispatch' || inputs.operation != 'probe'
271328 needs : resolve-release
@@ -278,6 +335,10 @@ jobs:
278335 with :
279336 ref : ${{ needs.resolve-release.outputs.release_sha }}
280337 path : .tmp/release-source
338+ - uses : actions/checkout@v4
339+ with :
340+ ref : ${{ github.sha }}
341+ path : .tmp/ci-controller
281342 - uses : astral-sh/setup-uv@v6
282343 with :
283344 version : " 0.10.11"
@@ -292,10 +353,13 @@ jobs:
292353 - run : make migrations-check
293354 - run : make django-check
294355 - run : make deployment-check
356+ - name : Run CI orchestration contract tests from the workflow controller
357+ working-directory : .tmp/ci-controller
358+ run : make test-ci
295359
296360 django :
297361 if : github.event_name != 'workflow_dispatch' || inputs.operation != 'probe'
298- needs : resolve-release
362+ needs : [ resolve-release, classification]
299363 runs-on : ubuntu-latest
300364 env :
301365 DJANGO_SETTINGS_MODULE : website.settings.test
@@ -309,18 +373,49 @@ jobs:
309373 ref : ${{ needs.resolve-release.outputs.release_sha }}
310374 path : .tmp/release-source
311375 fetch-depth : 0
376+ - uses : actions/checkout@v4
377+ with :
378+ ref : ${{ github.sha }}
379+ path : .tmp/ci-controller
312380 - uses : astral-sh/setup-uv@v6
313381 with :
314382 version : " 0.10.11"
315383 enable-cache : true
316384 - run : uv sync --locked
317385 - run : uv lock --check
386+ - name : Restore the reviewed CI selection
387+ uses : actions/download-artifact@v4
388+ with :
389+ name : ci-selection-${{ github.run_id }}-attempt-${{ github.run_attempt }}
390+ path : .tmp/release-source/.tmp/ci-selection
318391 - name : Create a fresh ordinary-CI SQLite database
319392 run : |
320393 mkdir -p .tmp
321394 rm -f .tmp/ci.sqlite3 .tmp/ci.sqlite3-shm .tmp/ci.sqlite3-wal
322395 - run : uv run python manage.py migrate --noinput
323- - run : make test
396+ - name : Validate the code-owned test selection
397+ working-directory : .tmp/ci-controller
398+ run : >-
399+ uv run --frozen python -m ci.classifier validate
400+ --input ../release-source/.tmp/ci-selection/ci-selection.json
401+ - name : Run the selected or complete Django suite
402+ env :
403+ CI_SELECTION_PATH : .tmp/ci-selection/ci-selection.json
404+ SELECTED_PROFILE : ${{ needs.classification.outputs.profile }}
405+ run : |
406+ set -euo pipefail
407+ case "$SELECTED_PROFILE" in
408+ focused)
409+ make test-ci-focused
410+ ;;
411+ full)
412+ make test
413+ ;;
414+ *)
415+ echo "Classifier emitted an unsupported profile" >&2
416+ exit 1
417+ ;;
418+ esac
324419
325420 playwright :
326421 if : github.event_name != 'workflow_dispatch' || inputs.operation != 'probe'
@@ -461,7 +556,7 @@ jobs:
461556 --env "APP_VERSION=$RELEASE_SHA" \
462557 --publish 8000:8000 "dtc-website:$RELEASE_SHA" web
463558 trap 'docker logs dtc-web; docker rm --force dtc-web' EXIT
464- for attempt in $(seq 1 30); do
559+ for _ in $(seq 1 30); do
465560 if test "$(curl --fail --silent http://127.0.0.1:8000/health/live)" = "{\"status\": \"ok\", \"version\": \"$RELEASE_SHA\"}"; then
466561 curl --fail --silent --output /dev/null http://127.0.0.1:8000/unified/
467562 exit 0
@@ -486,12 +581,62 @@ jobs:
486581 if-no-files-found : error
487582 overwrite : true
488583
584+ ci-gate :
585+ if : >-
586+ always() &&
587+ (github.event_name != 'workflow_dispatch' || inputs.operation != 'probe')
588+ # Preserve the release-image prerequisite boundary checked by the frozen
589+ # deployment contract: needs: [resolve-release, quality, django, playwright, container]
590+ needs : [resolve-release, classification, quality, django, playwright, container]
591+ runs-on : ubuntu-latest
592+ steps :
593+ - uses : actions/checkout@v4
594+ - uses : astral-sh/setup-uv@v6
595+ with :
596+ version : " 0.10.11"
597+ enable-cache : true
598+ - id : selection-download
599+ name : Restore classifier evidence for aggregate validation
600+ continue-on-error : true
601+ uses : actions/download-artifact@v4
602+ with :
603+ name : ci-selection-${{ github.run_id }}-attempt-${{ github.run_attempt }}
604+ path : .tmp/ci-selection
605+ - name : Require every non-probe release check
606+ env :
607+ RESOLVE_RESULT : ${{ needs.resolve-release.result }}
608+ CLASSIFICATION_RESULT : ${{ needs.classification.result }}
609+ QUALITY_RESULT : ${{ needs.quality.result }}
610+ DJANGO_RESULT : ${{ needs.django.result }}
611+ PLAYWRIGHT_RESULT : ${{ needs.playwright.result }}
612+ CONTAINER_RESULT : ${{ needs.container.result }}
613+ run : |
614+ uv run --frozen python -m ci.gate normal \
615+ --selection .tmp/ci-selection/ci-selection.json \
616+ --resolve-release "$RESOLVE_RESULT" \
617+ --classification "$CLASSIFICATION_RESULT" \
618+ --quality "$QUALITY_RESULT" \
619+ --django "$DJANGO_RESULT" \
620+ --playwright "$PLAYWRIGHT_RESULT" \
621+ --container "$CONTAINER_RESULT" \
622+ --output .tmp/ci-gate.json \
623+ --summary "$GITHUB_STEP_SUMMARY"
624+ - name : Preserve aggregate CI evidence
625+ if : always()
626+ uses : actions/upload-artifact@v4
627+ with :
628+ name : ci-gate-${{ github.run_id }}-attempt-${{ github.run_attempt }}
629+ path : .tmp/ci-gate.json
630+ if-no-files-found : warn
631+ retention-days : 30
632+
489633 auto-capture-prior :
490634 if : >-
635+ needs.ci-gate.result == 'success' &&
491636 github.event_name == 'push' &&
492637 vars.DEVELOPMENT_AUTO_DEPLOY == 'true' &&
493638 github.ref == 'refs/heads/main'
494- needs : [resolve-release, quality, django, playwright, container]
639+ needs : [resolve-release, classification, quality, django, playwright, container, ci-gate ]
495640 runs-on : ubuntu-latest
496641 environment :
497642 name : sandbox
@@ -623,17 +768,13 @@ jobs:
623768 publish :
624769 if : >-
625770 always() &&
626- needs.resolve-release.result == 'success' &&
627- needs.quality.result == 'success' &&
628- needs.django.result == 'success' &&
629- needs.playwright.result == 'success' &&
630- needs.container.result == 'success' &&
771+ needs.ci-gate.result == 'success' &&
631772 ((github.event_name == 'push' && vars.DEVELOPMENT_AUTO_DEPLOY == 'true' &&
632773 needs.auto-capture-prior.result == 'success') ||
633774 (github.event_name == 'workflow_dispatch' && inputs.deploy_development == true &&
634775 inputs.operation != 'probe' && needs.auto-capture-prior.result == 'skipped')) &&
635776 github.ref == 'refs/heads/main'
636- needs : [resolve-release, quality, django, playwright, container, auto-capture-prior]
777+ needs : [resolve-release, classification, quality, django, playwright, container, ci-gate , auto-capture-prior]
637778 runs-on : ubuntu-latest
638779 permissions :
639780 contents : read
@@ -813,13 +954,14 @@ jobs:
813954 deploy :
814955 if : >-
815956 always() &&
957+ needs.ci-gate.result == 'success' &&
816958 needs.publish.result == 'success' &&
817959 ((github.event_name == 'push' && vars.DEVELOPMENT_AUTO_DEPLOY == 'true' &&
818960 needs.auto-capture-prior.result == 'success') ||
819961 (github.event_name == 'workflow_dispatch' && inputs.deploy_development == true &&
820962 inputs.operation != 'probe' && needs.auto-capture-prior.result == 'skipped')) &&
821963 github.ref == 'refs/heads/main'
822- needs : [resolve-release, quality, django, playwright, container, auto-capture-prior, publish]
964+ needs : [resolve-release, classification, quality, django, playwright, container, ci-gate , auto-capture-prior, publish]
823965 runs-on : ubuntu-latest
824966 environment :
825967 name : sandbox
0 commit comments