Skip to content
Merged
7 changes: 6 additions & 1 deletion .github/workflows/simulation-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,12 @@ jobs:
entrypoint_revision_prefix: s-
entrypoint_min_instances: 0
entrypoint_max_instances: 2
promote_entrypoint: false
# Staging's stable URL must serve the revision this deployment tested,
# because staging callers such as the Public API's staging service use
# that URL. Only a run from main promotes: a manual run from another
# branch tests its candidate but leaves stable traffic and older
# revisions alone. Rule: sim.deploy.staging-promotes-tested-revision.
promote_entrypoint: ${{ github.ref == 'refs/heads/main' }}
run_full_integration: true
deploy_existing_stack: true
deploy_stage12_v2: true
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/stage12-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ jobs:
entrypoint_revision_prefix: s-
entrypoint_min_instances: 0
entrypoint_max_instances: 2
promote_entrypoint: false
# Like the main deployment, promote the tested staging revision, and
# only from main. Rule: sim.deploy.staging-promotes-tested-revision.
promote_entrypoint: ${{ github.ref == 'refs/heads/main' }}
# This workflow deploys and validates only the independent Stage 12 path.
# The main API deployment continues to run the existing-stack suite.
run_full_integration: false
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`.

### Deployment pipeline

- Checked: `sim.deploy.container-context-minimal`, `sim.deploy.docker-images-build`, `sim.deploy.gcloud-without-terraform`, `sim.deploy.modal-image-smoke`, `sim.deploy.numerical-sentinel-gate`, `sim.deploy.permission-enforcement-wired`, `sim.deploy.promotion-order-explicit`, `sim.deploy.release-commit-deploys-once`, `sim.deploy.release-identity-smoke`, `sim.deploy.revision-tags-valid`, `sim.deploy.routing-publication-journaled`, `sim.deploy.shell-scripts-valid`, `sim.deploy.smoke-tests-strict`, `sim.deploy.traffic-changes-exact`, `sim.deploy.workflow-shell-safety`
- Checked: `sim.deploy.container-context-minimal`, `sim.deploy.docker-images-build`, `sim.deploy.gcloud-without-terraform`, `sim.deploy.modal-image-smoke`, `sim.deploy.numerical-sentinel-gate`, `sim.deploy.permission-enforcement-wired`, `sim.deploy.promotion-order-explicit`, `sim.deploy.release-commit-deploys-once`, `sim.deploy.release-identity-smoke`, `sim.deploy.revision-tags-valid`, `sim.deploy.routing-publication-journaled`, `sim.deploy.shell-scripts-valid`, `sim.deploy.smoke-tests-strict`, `sim.deploy.staging-promotes-tested-revision`, `sim.deploy.traffic-changes-exact`, `sim.deploy.workflow-shell-safety`
- `sim.deploy.image-smoke-covers-every-image-lib`: The image smoke path filter includes every library mounted into a Modal image, so a change to any of them runs the smoke. (doc-only, not checked: follow it yourself)

### API contracts and integration
Expand Down
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,21 @@ push deploys one complete stack at a time (see
three parallel service jobs;
3. publish routing only after all three service jobs pass;
4. run generated-client and live authentication tests through the complete
three-service request path; and
5. begin the `prod` deployment only after every `beta` deployment and
qualification job succeeds;
6. assign stable production traffic to the exact tested entrypoint revision
after the complete production suite passes; and
7. verify the stable endpoint, restoring its previous revision if immediate
post-promotion checks fail.
three-service request path;
5. assign the environment's stable traffic to the exact tested entrypoint
revision after that environment's tests pass, in `beta` as in `prod`
(`beta` only for a run from `main`);
6. verify the stable endpoint, restoring its previous revision if immediate
post-promotion checks fail; and
7. begin the `prod` deployment only after every `beta` deployment,
qualification, promotion and revision clean-up job succeeds, so a failed
staging clean-up also stops the production deployment.

The staging service's stable URL therefore serves the revision that passed the
staging tests, so staging callers of that URL, such as the Public API's staging
service, reach the build the deployment tested. After each successful
promotion, the deployment deletes the service's older revisions, keeping only
the promoted revision and the one it replaced, for rollback.

A successful deployment of a release commit publishes the API client from the
exact deployed commit. A manual dispatch of the workflow deploys without a
Expand Down
1 change: 1 addition & 0 deletions changelog.d/893.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Promoted each staging deployment's tested Simulation Entrypoint revision to the staging service's stable traffic, as production deployments already do, in both the main deployment's beta job and the Stage 12 comparison path's staging deployment. The staging stable URL now serves the build that passed the staging tests; promotion runs the same stable-URL checks as production's and restores the previous revision when they fail, and the existing clean-up then deletes the staging service's older revisions except the promoted one and the one it replaced. Only a run from main promotes: a manual staging run from another branch tests its candidate but leaves the staging service's Cloud Run traffic and older revisions unchanged. The new rule sim.deploy.staging-promotes-tested-revision requires it.
7 changes: 5 additions & 2 deletions docs/generated/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf

| Tier | enforced | ratcheted | advisory | doc-only | Total |
| --- | ---: | ---: | ---: | ---: | ---: |
| mechanical | 56 | 2 | 0 | 14 | 72 |
| mechanical | 57 | 2 | 0 | 14 | 73 |
| metric | 1 | 0 | 0 | 0 | 1 |
| judgment | 0 | 0 | 0 | 0 | 0 |

**Blocking:** 59 of 73 rules.
**Blocking:** 60 of 74 rules.

## Gaps

Expand Down Expand Up @@ -156,6 +156,7 @@ flowchart LR
| `sim.deploy.routing-publication-journaled` | Each routing publication is named by its run, attempt and environment and journaled before it is applied; an always-running finalizer resolves it from the journal with the release-gate outcome, a release reports success only after that finalizer completes, and routing jobs never tolerate a failed command. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_release_gates.py`, `projects/policyengine-simulation-entry/tests/test_deployment_assets_routing.py` | `pr.yml:test` | **unproven** |
| `sim.deploy.shell-scripts-valid` | Deployment shell scripts have valid shell syntax. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** |
| `sim.deploy.smoke-tests-strict` | Candidate and stable smoke tests reject a revision mismatch without retrying, and fail if the previous revision does not converge. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py` | `pr.yml:test` | **unproven** |
| `sim.deploy.staging-promotes-tested-revision` | Every staging deployment passes promote_entrypoint: ${{ github.ref == 'refs/heads/main' }}, so a run from main moves the staging service's stable Cloud Run traffic to the entrypoint revision that passed the staging tests, and an accidental manual run from another branch neither moves that traffic nor deletes older revisions. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_promotion.py` | `pr.yml:test` | yes |
| `sim.deploy.traffic-changes-exact` | Traffic changes target an exact, validated revision, are reversible, and refuse to run over an intervening promotion. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py` | `pr.yml:test` | **unproven** |
| `sim.deploy.workflow-shell-safety` | Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, and pins secret versions. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py` | `pr.yml:test` | **unproven** |

Expand Down Expand Up @@ -189,6 +190,7 @@ flowchart LR
e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_release_gates_py__projects_policyengine_simulation_entry_tests_test_deployment_assets_routing_py["pytest projects/policyengine-simulation-entry/tests/test_deployment_assets_release_gates.py, projects/policyengine-simulation-entry/tests/test_deployment_assets_routing.py"]
r_sim_deploy_shell_scripts_valid("sim.deploy.shell-scripts-valid (enforced)")
r_sim_deploy_smoke_tests_strict("sim.deploy.smoke-tests-strict (enforced)")
r_sim_deploy_staging_promotes_tested_revision("sim.deploy.staging-promotes-tested-revision (enforced)")
r_sim_deploy_traffic_changes_exact("sim.deploy.traffic-changes-exact (enforced)")
e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_traffic_py["pytest projects/policyengine-simulation-entry/tests/test_deployment_assets_traffic.py"]
r_sim_deploy_workflow_shell_safety("sim.deploy.workflow-shell-safety (enforced)")
Expand Down Expand Up @@ -216,6 +218,7 @@ flowchart LR
e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_release_gates_py__projects_policyengine_simulation_entry_tests_test_deployment_assets_routing_py --> j_CI_pr_yml_test
r_sim_deploy_shell_scripts_valid --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py
r_sim_deploy_smoke_tests_strict --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_py
r_sim_deploy_staging_promotes_tested_revision --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_promotion_py
r_sim_deploy_traffic_changes_exact --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_traffic_py
e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_traffic_py --> j_CI_pr_yml_test
r_sim_deploy_workflow_shell_safety --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_cloud_run_py
Expand Down
70 changes: 54 additions & 16 deletions projects/policyengine-simulation-entry/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,24 +33,61 @@ qualification uses Cloud Run's generated tagged revision URL and does not
require a custom domain.

The `prod` environment follows the same sequence automatically, but cannot
begin until every `beta` deployment, routing, integration, and authentication
job has succeeded. Production tests also use the tagged candidate URL, so the
currently serving revision remains unchanged during qualification.
begin until every `beta` deployment, routing, integration, authentication,
promotion, and revision clean-up job has succeeded, so a failed staging
clean-up also stops the production deployment. A run from a branch other than
`main` skips `beta`'s promotion and clean-up, and the `prod` environment then
refuses the run, since it accepts only `main`. Both environments' tests use
the tagged candidate URL, so the currently serving revision remains unchanged
during qualification.

## Promotion and rollback

After the complete production suite passes, the workflow verifies that the
exact tested revision belongs to the production service and is ready. It also
Both `beta` and `prod` promote, `beta` only for a run from `main`. After an
environment's complete suite passes, the workflow verifies that the exact
tested revision belongs to that environment's service and is ready. It also
checks that stable traffic still points to the revision recorded before
deployment. If those guards pass, it assigns all stable service traffic to the
tested production revision.
deployment. If those checks pass, it assigns all stable service traffic to the
tested revision.

In `beta`, promotion keeps the staging service's stable URL on the revision
that passed the staging tests, so staging callers of that URL, such as the
Public API's staging service, reach the build the deployment tested. The
staging deployment of the **Deploy Stage 12 comparison path** workflow promotes
in the same way. Rule `sim.deploy.staging-promotes-tested-revision` requires
both.

The workflow then checks the stable generated Cloud Run URL. It uses the
runtime revision header to prove that the stable URL reaches the exact revision
that passed candidate tests, and it repeats the deployed authentication checks.
If an immediate post-promotion check fails, the workflow restores the exact
previous revision recorded before deployment and fails visibly.

After a successful promotion and deployment, the workflow deletes the
service's older revisions. It keeps only the promoted revision and the one it
replaced, which `simulation-rollback.yml` can route back to.

Both workflows can also be started manually, and the `beta` environment does
not restrict which branch a run may come from. Both therefore pass
`promote_entrypoint: ${{ github.ref == 'refs/heads/main' }}`. A manual run from
a branch other than `main` deploys and tests its candidate revision, but leaves
the staging service's Cloud Run traffic where it was and deletes no revisions,
so the last revision deployed from `main` stays available for rollback.

Promotion covers only the Cloud Run entrypoint. A branch run still deploys the
branch's Modal services to the staging Modal environment and publishes their
routing there, so callers of the staging stable URL can reach them: the main
deployment workflow deploys the gateway, the executor and the Stage 12 workers,
and the Stage 12 workflow deploys the Stage 12 workers. A later deployment from
`main` replaces the apps it deploys, but routes and versions that it doesn't
deploy can stay.

The check reads the branch's own copy of the workflow: it stops an accidental
branch run from promoting, not a branch that edits it. Only a branch
restriction on the `beta` environment would enforce it, and that would also
refuse every branch run's candidate deployment and tests, since each of the
reusable workflow's deployment jobs runs in that environment.

Deployment concurrency prevents two releases from changing the entrypoint
service simultaneously. Promotion never targets `LATEST`.

Expand Down Expand Up @@ -82,10 +119,10 @@ The deployment workflow passes each environment's
unset or empty variable deploys `0`, which records callers that lack a route's
permission but lets them through. `1` rejects those callers with 403. Any other
value fails the deployment before Cloud Run changes. A new value takes effect
at the next entrypoint deployment. In `prod` that deployment is promoted to
stable traffic. `beta` never promotes its candidate revision, so a `beta` value
applies only to the tagged candidate that the deployment tests call. The route
permissions are listed in `README.md`.
at the next entrypoint deployment, which `prod`, and `beta` for a run from
`main`, promote to stable traffic once their tests pass. A `beta` value
therefore applies to every caller of the staging stable URL as well as to the
deployment tests. The route permissions are listed in `README.md`.

### Auth0 prerequisites

Expand Down Expand Up @@ -117,11 +154,12 @@ variable to `1`:
1. Deploy with the variable unset or `0`, and complete the Auth0 setup.
2. Watch the `prod` service for `simulation_entry_permission_missing` events
with `enforced=false`. Each one names the missing permission and the
caller's `client_id`. Only `prod` serves real callers such as API v1: a
`beta` candidate receives only the deployment tests' own requests, so `beta`
events cannot show a missing API v1 grant. Access tokens issued before a
grant do not carry it, so wait at least one access-token lifetime after the
last grant with no new events.
caller's `client_id`. Only `prod` is certain to receive requests from every
real caller: the staging stable URL receives requests only from the staging
callers configured to use it, and tagged candidates only from the
deployment tests, so `beta` events cannot show every missing grant. Access
tokens issued before a grant do not carry it, so wait at least one
access-token lifetime after the last grant with no new events.
3. Set the `beta` variable to `1` and deploy. With enforcement on, the
authenticated deployment tests also check that the test client's token is
rejected on `GET /internal/stage12/reports/{evaluation_id}`, which requires
Expand Down
5 changes: 3 additions & 2 deletions projects/policyengine-simulation-entry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ Cloud Run's generated revision name is returned in a response header so
deployment checks can prove which revision a tagged or stable URL serves.

Live caller/backend authentication checks are isolated in `authenticated_tests`.
They qualify tagged Cloud Run candidates before production traffic changes and
verify the stable production endpoint immediately after promotion.
They qualify tagged Cloud Run candidates before an environment's stable traffic
changes and verify its stable endpoint immediately after promotion, in both
staging and production.

## Caller authentication and permissions

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
AUTHENTICATED_TESTS,
CLOUD_RUN_DEPLOY_SCRIPT,
DEPLOY_WORKFLOW,
MAIN_ONLY_PROMOTION,
REPOSITORY_ROOT,
REUSABLE_DEPLOY_WORKFLOW,
SMOKE_SCRIPT,
Expand Down Expand Up @@ -198,8 +199,10 @@ def _assert_beta_then_prod_release(
assert "deployment_environment: staging" in deploy_workflow
assert "deployment_environment: production" in deploy_workflow
assert deploy_workflow.count("deploy_existing_stack: true") == 2
assert "promote_entrypoint: false" in deploy_workflow
assert "promote_entrypoint: true" in deploy_workflow
# prod always promotes; beta promotes only a run from main.
assert deploy_workflow.count("promote_entrypoint: true") == 1
assert f"promote_entrypoint: {MAIN_ONLY_PROMOTION}" in deploy_workflow
assert "promote_entrypoint: false" not in deploy_workflow
assert "entrypoint_environment" not in deploy_workflow
assert "entrypoint_environment" not in reusable_workflow
assert "entrypoint_public_url" not in deploy_workflow
Expand Down
Loading
Loading