Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/actions/deployment-gate/deployment-gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@ while [ "$SECONDS" -lt "$deadline" ]; do
fi
scheduled_for="$(jq -r '.scheduled_for // empty' <<<"$gate")"
if [ -n "$scheduled_for" ]; then waiting+=" scheduled_for=$scheduled_for"; fi
# The environment requires an approved change ticket (#993): say which one we wait on.
if [ "$(jq -r '.change_ticket_required // false' <<<"$gate")" = "true" ]; then
waiting+=" change_ticket=$(jq -r 'if .change_ticket then "\(.change_ticket.external_key) (\(.change_ticket.status // "unknown")\(if .change_ticket.decision then ", \(.change_ticket.decision)" else "" end))" else "none linked yet" end' <<<"$gate")"
fi
echo "$waiting, waiting ${interval_s}s…"
sleep "$interval_s"
done
Expand Down
20 changes: 20 additions & 0 deletions .github/actions/tests/ci-templates-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,17 @@ assert_body_field 1 '.external_run_id' "$CI_PIPELINE_ID"
assert_body_field 1 '.run_url' "$CI_PIPELINE_URL"
assert_body_field 1 '.commit_sha' "$CI_COMMIT_SHA"

scenario "gitlab-gate-logs-the-change-ticket-wait"
resp 1 202 '{"id":"req-ct","status":"PENDING_AI"}'
resp 2 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true}'
resp 3 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true,"change_ticket":{"external_key":"CHG-7","decision":"REJECTED"}}'
resp 4 200 '{"request_id":"req-ct","status":"APPROVED","releasable":true}'
resp 5 200 '{"id":"req-ct","status":"EXECUTED"}'
run "$scripts/gitlab-deployment-gate.sh"
assert_eq "exit code" 0 "$run_rc"
assert_log_contains "change_ticket=none linked yet"
assert_log_contains "change_ticket=CHG-7 REJECTED"

scenario "gitlab-gate-404-fails-closed"
resp 1 202 '{"id":"req-2","status":"PENDING_AI"}'
resp 2 404 '{"title":"Not Found"}'
Expand Down Expand Up @@ -243,6 +254,15 @@ assert_log_contains "rate limited (HTTP 429), retrying submission in 1s"
assert_log_contains "rate limited (HTTP 429), retrying in 1s"
assert_log_contains "confirm rate limited (HTTP 429), retrying in 1s"

scenario "azure-gate-logs-the-change-ticket-wait"
resp 1 202 '{"id":"req-ct","status":"PENDING_AI"}'
resp 2 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true,"change_ticket":{"external_key":"CHG-8"}}'
resp 3 200 '{"request_id":"req-ct","status":"APPROVED","releasable":true}'
resp 4 200 '{"id":"req-ct","status":"EXECUTED"}'
run "$scripts/azure-deployment-gate.sh"
assert_eq "exit code" 0 "$run_rc"
assert_log_contains "change_ticket=CHG-8"

scenario "azure-gate-404-fails-closed"
resp 1 202 '{"id":"req-7","status":"PENDING_AI"}'
resp 2 404 '{"title":"Not Found"}'
Expand Down
14 changes: 14 additions & 0 deletions .github/actions/tests/deployment-gate-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ assert_body_field 1 '.justification' "ship it"
assert_body_field 1 '.metadata.changelog' "fix things"
assert_body_field 1 '.break_glass // "absent"' "absent"

scenario "change-ticket-wait-is-logged"
resp 1 202 '{"id":"req-ct","status":"PENDING_AI"}'
resp 2 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true}'
resp 3 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true,"change_ticket":{"system":"SERVICENOW","external_key":"CHG0012345","status":"Assess"}}'
resp 4 200 '{"request_id":"req-ct","status":"APPROVED","releasable":false,"change_ticket_required":true,"change_ticket":{"external_key":"CHG0012346","status":"Closed","decision":"REJECTED"}}'
resp 5 200 '{"request_id":"req-ct","status":"APPROVED","releasable":true,"change_ticket_required":true,"change_ticket":{"external_key":"CHG0012345","status":"Implement","decision":"APPROVED"}}'
resp 6 200 '{"id":"req-ct","status":"EXECUTED"}'
run "$gate_script"
assert_eq "exit code" 0 "$run_rc"
assert_eq "confirm-execution calls" 1 "$(confirm_calls)"
assert_log_contains "change_ticket=none linked yet"
assert_log_contains "change_ticket=CHG0012345 (Assess)"
assert_log_contains "change_ticket=CHG0012346 (Closed, REJECTED)"

scenario "rejected-fails-without-confirming"
resp 1 202 '{"id":"req-2","status":"PENDING_AI"}'
resp 2 200 '{"request_id":"req-2","status":"PENDING_REVIEW","releasable":false}'
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ com.bablsoft.accessflow/
│ ├── api/
│ ├── events/
│ └── internal/ # persistence, client (per-protocol exec + auth + prober), schema (parsers), routing, scheduled, web
├── deploygov/ # Deployment approval governance (epic AF-682): gate CI/CD deployments behind approval workflows. #684 landed the persistence foundation — pipelines/environments/freeze windows/requests/decisions/routing/grants (reusing query_status, submission_reason, decision, api_routing_action) + DEPLOYMENT_PIPELINE_MANAGE / DEPLOYMENT_REVIEW permissions; #688 adds the admin surface — pipeline/environment/permission/freeze-window CRUD services + controllers, the effective-permission resolver (most-permissive union of direct + unexpired group grants), and the fail-closed FreezeWindowEvaluator; #691 adds the submission half — the API-key-authenticated trigger endpoint (idempotent on the CI run), `ai.api.DeploymentAnalyzer` + the deploygov-side analysis listener, the typed-conditions DeploymentRoutingPolicyEngine with admin CRUD, and the transition-validating DeploymentRequestStateService; #692 adds the human half — the review decision endpoints (`/api/v1/deployment-reviews`, single-stage, idempotent, self-approval → 409, plan-approver eligibility without delegation), deployment break-glass (can_break_glass grant AND environment allow_break_glass, no admin bypass; force-approve, freeze bypass audited via DEPLOYMENT_BREAK_GLASS_EXECUTED, mandatory retro-review in break_glass_events via the synchronous workflow listener), and the plan-driven DeploymentTimeoutJob (`accessflow.deploygov.timeout-check`); #693 adds the machine contract — the fail-closed gate (`GET /api/v1/deployment-gate`, one pure releasable function: APPROVED, no active freeze window (HOLD or REJECT; break-glass exempt), scheduled_for passed; any error → not releasable; 404-never-403 visibility), confirm-execution (`APPROVED → EXECUTED`, releasable-gated, audited DEPLOYMENT_EXECUTED trigger=pipeline), idempotent outcome reporting (conflict → 409 DEPLOYMENT_OUTCOME_CONFLICT; FAILED flips EXECUTED → FAILED; ROLLED_BACK on a require_review environment opens a deployment_rollback_reviews follow-up the submitter can never acknowledge), and the notify-only ScheduledDeploymentReleaseJob (`accessflow.deploygov.release-check`, one-shot DeploymentReleasableEvent via release_notified_at); #694 adds the CI wrappers — the `.github/actions/deployment-gate` + `deployment-outcome` composite actions, the `.accessflow_deployment_gate`/`_outcome` GitLab hidden jobs (`ci-templates/gitlab/accessflow-deployment.gitlab-ci.yml`), the Azure step template (`ci-templates/azure/`), and the generic curl walkthrough — all submit-idempotently-by-run-id → poll-gate → confirm → report-outcome, fail-closed (gate 404 or any non-retryable error fails the job), pipeline identified by UUID (a trigger-only key cannot resolve names), offline-tested via the fake-curl harness in `.github/actions/tests/`; #695 adds the human fan-out — five `DEPLOYMENT_*` NotificationEventType values (submitted-on-PENDING_REVIEW → eligible reviewers, approved/rejected → submitter, outcome-failed → the granting approvers, break-glass → all admins + PagerDuty via the existing BREAK_GLASS trigger), `notifications/internal/DeploymentNotificationListener` + `deploygov/api/DeploymentNotificationLookupService`, `user_notifications.deployment_request_id` (V155), audit actions for every remaining transition (SUBMITTED/APPROVED/REJECTED/TIMED_OUT/CANCELLED + DEPLOYMENT_BREAK_GLASS_REVIEWED; system rows carry null actor + `trigger=routing|freeze|timeout|environment_policy`), and no ticketing (queries never ticket on submission either; admin-CRUD audit moved to a follow-up); #696 adds the web UI — `/deployments`, `/deployments/:id`, `/deployment-reviews` (with the rollback worklist; since #772 both are tabs of the unified `/reviews` hub — `?tab=deployments|rollbacks` — and the old URL redirects), `/admin/deployment-pipelines` and its settings tabs (environments, permissions, freeze windows, routing policies, CI snippet panel), all `t()`-keyed under `deploygov.*`; #697 is the docs/website sweep and adds [`docs/18-deployment-governance.md`](docs/18-deployment-governance.md) — read that chapter before touching the module; #741 adds multi-environment version tracking — free-form `tags` on deployment_environments (≤10, ≤32 chars, the query_templates.tags precedent) and the one-row-per-environment `deployment_environment_versions` read model maintained by the module-private DeploymentVersionTrackerService inside the EXECUTED/outcome transactions (shift-on-execute, single-level revert on FAILED/ROLLED_BACK for the current request, non-current outcomes no-op; never feeds gate/approval/routing; UI follows in #743); #742 adds the read-only version inventory & drift API — `deploygov/api/DeploymentVersionInventoryService` behind three endpoints (per-pipeline matrix, org-wide filterable matrix, per-environment history straight from deployment_requests), drift computed at read time only (no job, no notifications, no semver — plain string inequality vs the pipeline's newest successful deploy over the UNFILTERED row set), timed by `deployment_requests.executed_at` (V157, stamped at the state-service EXECUTED chokepoint); per-pipeline visibility = MANAGE/REVIEW/QUERY_ADMIN/effective can_trigger failing 404-never-403, org-wide matrix functional-permission-only; #967 adds the decision trace — a pure `DeploymentDecisionEvaluator` split out of the review state machine, and `POST /admin/deployment-simulations`, which walks the trigger grant, freeze window, routing, environment policy, approvers, schedule and the gate's own `releasable` function at an optional hypothetical instant `at`; #877 (epic #870, schema change sets) makes environments schema-promotion targets — nullable `deployment_environments.datasource_id` (bare UUID, no FK, validated via `core.api.DatasourceAdminService.getForAdmin` → the existing `404 DATASOURCE_NOT_FOUND`; `clearDatasource` unbinds) and a **real ladder**: V177 backfills `sort_order` to distinct contiguous values per pipeline and adds `UNIQUE (pipeline_id, sort_order)`; the service appends an omitted position at `max+1`, pre-checks an explicit duplicate and translates the raced unique violation to `409 DEPLOYMENT_ENVIRONMENT_SORT_ORDER_CONFLICT` (never default `sort_order` to `0` again — a "lower-ordered environments applied" gate over all-zero rows evaluates the empty set and fails open); two narrow api lookups — `DeploymentEnvironmentLookupService` (ladder + one-by-id, not org-scoped: resolve the pipeline first) and `DeploymentFreezeLookupService` (pure delegation to the package-private `FreezeWindowEvaluator`, preserving fail-closed `HOLD` / most-specific / `REJECT`-beats-`HOLD`) — exist so `schemachange` never imports `deploygov.internal`; #992 adds ticket-driven decisions — `api.DeploymentExternalDecisionService` (system-attributed: no decision row, null actor, `trigger=ticket`, `false` on a lost race) reached only through `workflow.api.ExternalDecisionService.applyDeploymentTicketDecision`, because `workflow → deploygov` already exists and the reverse is a cycle; the inbound webhook stamps `deployment_tickets.decision` on every verified callback and decides only with `bidirectional_sync`
├── deploygov/ # Deployment approval governance (epic AF-682): gate CI/CD deployments behind approval workflows. #684 landed the persistence foundation — pipelines/environments/freeze windows/requests/decisions/routing/grants (reusing query_status, submission_reason, decision, api_routing_action) + DEPLOYMENT_PIPELINE_MANAGE / DEPLOYMENT_REVIEW permissions; #688 adds the admin surface — pipeline/environment/permission/freeze-window CRUD services + controllers, the effective-permission resolver (most-permissive union of direct + unexpired group grants), and the fail-closed FreezeWindowEvaluator; #691 adds the submission half — the API-key-authenticated trigger endpoint (idempotent on the CI run), `ai.api.DeploymentAnalyzer` + the deploygov-side analysis listener, the typed-conditions DeploymentRoutingPolicyEngine with admin CRUD, and the transition-validating DeploymentRequestStateService; #692 adds the human half — the review decision endpoints (`/api/v1/deployment-reviews`, single-stage, idempotent, self-approval → 409, plan-approver eligibility without delegation), deployment break-glass (can_break_glass grant AND environment allow_break_glass, no admin bypass; force-approve, freeze bypass audited via DEPLOYMENT_BREAK_GLASS_EXECUTED, mandatory retro-review in break_glass_events via the synchronous workflow listener), and the plan-driven DeploymentTimeoutJob (`accessflow.deploygov.timeout-check`); #693 adds the machine contract — the fail-closed gate (`GET /api/v1/deployment-gate`, one pure releasable function: APPROVED, no active freeze window (HOLD or REJECT; break-glass exempt), scheduled_for passed; any error → not releasable; 404-never-403 visibility), confirm-execution (`APPROVED → EXECUTED`, releasable-gated, audited DEPLOYMENT_EXECUTED trigger=pipeline), idempotent outcome reporting (conflict → 409 DEPLOYMENT_OUTCOME_CONFLICT; FAILED flips EXECUTED → FAILED; ROLLED_BACK on a require_review environment opens a deployment_rollback_reviews follow-up the submitter can never acknowledge), and the notify-only ScheduledDeploymentReleaseJob (`accessflow.deploygov.release-check`, one-shot DeploymentReleasableEvent via release_notified_at); #694 adds the CI wrappers — the `.github/actions/deployment-gate` + `deployment-outcome` composite actions, the `.accessflow_deployment_gate`/`_outcome` GitLab hidden jobs (`ci-templates/gitlab/accessflow-deployment.gitlab-ci.yml`), the Azure step template (`ci-templates/azure/`), and the generic curl walkthrough — all submit-idempotently-by-run-id → poll-gate → confirm → report-outcome, fail-closed (gate 404 or any non-retryable error fails the job), pipeline identified by UUID (a trigger-only key cannot resolve names), offline-tested via the fake-curl harness in `.github/actions/tests/`; #695 adds the human fan-out — five `DEPLOYMENT_*` NotificationEventType values (submitted-on-PENDING_REVIEW → eligible reviewers, approved/rejected → submitter, outcome-failed → the granting approvers, break-glass → all admins + PagerDuty via the existing BREAK_GLASS trigger), `notifications/internal/DeploymentNotificationListener` + `deploygov/api/DeploymentNotificationLookupService`, `user_notifications.deployment_request_id` (V155), audit actions for every remaining transition (SUBMITTED/APPROVED/REJECTED/TIMED_OUT/CANCELLED + DEPLOYMENT_BREAK_GLASS_REVIEWED; system rows carry null actor + `trigger=routing|freeze|timeout|environment_policy`), and no ticketing (queries never ticket on submission either; admin-CRUD audit moved to a follow-up); #696 adds the web UI — `/deployments`, `/deployments/:id`, `/deployment-reviews` (with the rollback worklist; since #772 both are tabs of the unified `/reviews` hub — `?tab=deployments|rollbacks` — and the old URL redirects), `/admin/deployment-pipelines` and its settings tabs (environments, permissions, freeze windows, routing policies, CI snippet panel), all `t()`-keyed under `deploygov.*`; #697 is the docs/website sweep and adds [`docs/18-deployment-governance.md`](docs/18-deployment-governance.md) — read that chapter before touching the module; #741 adds multi-environment version tracking — free-form `tags` on deployment_environments (≤10, ≤32 chars, the query_templates.tags precedent) and the one-row-per-environment `deployment_environment_versions` read model maintained by the module-private DeploymentVersionTrackerService inside the EXECUTED/outcome transactions (shift-on-execute, single-level revert on FAILED/ROLLED_BACK for the current request, non-current outcomes no-op; never feeds gate/approval/routing; UI follows in #743); #742 adds the read-only version inventory & drift API — `deploygov/api/DeploymentVersionInventoryService` behind three endpoints (per-pipeline matrix, org-wide filterable matrix, per-environment history straight from deployment_requests), drift computed at read time only (no job, no notifications, no semver — plain string inequality vs the pipeline's newest successful deploy over the UNFILTERED row set), timed by `deployment_requests.executed_at` (V157, stamped at the state-service EXECUTED chokepoint); per-pipeline visibility = MANAGE/REVIEW/QUERY_ADMIN/effective can_trigger failing 404-never-403, org-wide matrix functional-permission-only; #967 adds the decision trace — a pure `DeploymentDecisionEvaluator` split out of the review state machine, and `POST /admin/deployment-simulations`, which walks the trigger grant, freeze window, routing, environment policy, approvers, schedule and the gate's own `releasable` function at an optional hypothetical instant `at`; #877 (epic #870, schema change sets) makes environments schema-promotion targets — nullable `deployment_environments.datasource_id` (bare UUID, no FK, validated via `core.api.DatasourceAdminService.getForAdmin` → the existing `404 DATASOURCE_NOT_FOUND`; `clearDatasource` unbinds) and a **real ladder**: V177 backfills `sort_order` to distinct contiguous values per pipeline and adds `UNIQUE (pipeline_id, sort_order)`; the service appends an omitted position at `max+1`, pre-checks an explicit duplicate and translates the raced unique violation to `409 DEPLOYMENT_ENVIRONMENT_SORT_ORDER_CONFLICT` (never default `sort_order` to `0` again — a "lower-ordered environments applied" gate over all-zero rows evaluates the empty set and fails open); two narrow api lookups — `DeploymentEnvironmentLookupService` (ladder + one-by-id, not org-scoped: resolve the pipeline first) and `DeploymentFreezeLookupService` (pure delegation to the package-private `FreezeWindowEvaluator`, preserving fail-closed `HOLD` / most-specific / `REJECT`-beats-`HOLD`) — exist so `schemachange` never imports `deploygov.internal`; #992 adds ticket-driven decisions — `api.DeploymentExternalDecisionService` (system-attributed: no decision row, null actor, `trigger=ticket`, `false` on a lost race) reached only through `workflow.api.ExternalDecisionService.applyDeploymentTicketDecision`, because `workflow → deploygov` already exists and the reverse is a cycle; the inbound webhook stamps `deployment_tickets.decision` on every verified callback and decides only with `bidirectional_sync`; #993 makes the ticket a control — a fourth conjunct of the gate's pure `releasable` function on `change_ticket_required` environments, read from `deployment_tickets` rows only (≥ 1 `APPROVED`, none `REJECTED`; no row, unresolved rows or a lookup error → not releasable, so a flagged environment with no ticketing channel carrying the trigger never releases), skipped for break-glass and audited on `DEPLOYMENT_EXECUTED` as `change_ticket_key` / `change_ticket_bypassed=break_glass`; an `APPROVED` stamp is sticky in `DefaultDeploymentTicketService.updateStatus` (an unmapped later status keeps it, only a `REJECTED` mapping overrides it — never revert to "null always replaces", or a change moving to *Implement* closes an opened gate); the decision trace always answers not releasable there (a hypothetical request has no ticket)
│ ├── api/
│ ├── events/
│ └── internal/ # persistence, services, resolver/evaluator, scheduled, web
Expand Down
Loading
Loading