Skip to content

Run production's E2E rollback in its own job, away from npm - #590

Merged
jehanazad merged 2 commits into
mainfrom
ci/prod-e2e-rollback-job
Oct 1, 2026
Merged

jehanazad merged 2 commits into
mainfrom
ci/prod-e2e-rollback-job

Conversation

@Babissimo

Copy link
Copy Markdown
Contributor

ClickUp: 1.4 Run the production E2E rollback in its own job, away from npm

Why

e2e-prod runs npm ci and Playwright inside the Playwright image, and then, in the same job, handed SERVER_SSH_KEY (root on production) to its rollback step. Every install script in the npm tree, and the image itself, ran first in the environment the key was later given to.

What changes

  • The rollback moves to rollback-production-on-e2e-failure: its own job, on the runner, needs: e2e-prod.
  • It acts on the test step's outcome, exported from e2e-prod as the job output tests, not on the job's result. A failed pull, checkout or npm ci still leaves production alone, as before.
  • It is gated on !cancelled() rather than always(), so cancelling the run still stops it, as it stopped the in-job step.
  • It takes production-deploy, like rollback-production-on-deploy-failure, so a following run's deploy cannot be in the deploy directory while rollback.sh is.
  • Its appleboy/ssh-action is pinned by SHA (v1.2.5, what v1 points at today).
  • test_deploy_workflows.py holds every workflow to one rule: a job that installs packages, runs an action other than checkout or the ssh transport, runs in a container or starts service containers cannot read a droplet key. It also evaluates the new job's if: across refs, events, test outcomes and cancellation. Against main's ci.yml the rule fails on e2e-prod, and swapping !cancelled() for always() fails the matrix.

Known limits, closed later in this stack

  • production-smoke-tests and e2e-prod still hold no lock, so a following run's deploy can land while this run is still verifying, and its rollback would then act on that deploy. The next PR (1.1) runs the whole production chain under one calling job that holds production-deploy, which also removes the rollbacks' own groups and with them the one-pending-job cancellation. 1.6 adds a per-run deploy record the rollback checks.
  • Actions reuses a failed job's outputs from the previous attempt on "Re-run failed jobs" (Failed job outputs from previous attempts re-used on "Re-run failed jobs" actions/runner#2598), so re-running e2e-prod after a rollback could fire a second one if the re-run never reaches the test step. The second rollback.sh restores the same snapshot.

Verification

  • backend/tests/test_deploy_workflows.py, and the backend suite with CI's flags.
  • actionlint on the three deploy workflows.
  • Not exercised on retina-test: deploy-test.yml has no E2E stage, so it cannot drive this job, and rollback.sh is unchanged. The first real exercise is a failing production E2E.
  • tower-finder-service's ci.yml has no job holding a droplet key beside an install step, so it needs no mirror.

🤖 Generated with Claude Code

@claude

This comment has been minimized.

@Babissimo
Babissimo force-pushed the ci/prod-e2e-rollback-job branch from 33176c8 to cd278f4 Compare September 28, 2026 09:28
@claude

This comment has been minimized.

@Babissimo

Copy link
Copy Markdown
Contributor Author

On the "dead parametrization" note: the cancelled parameter isn't inert for the deploy-failure rollback. It pins that that rollback still runs when the run is cancelled, which is why it uses always() rather than !cancelled(): a cancel mid-deploy leaves production as half-deployed as a crash does.

Checked on cd278f4 by swapping that job's always() for !cancelled() and running the test. Exactly the two cancelled=True main-push cases fail:

  • [True-failure-push-refs/heads/main]
  • [True-cancelled-push-refs/heads/main]

With always() restored, all 72 cases pass. Without the parameter, that swap would pass unnoticed, so it stays.

e2e-prod runs npm ci, and the same job handed SERVER_SSH_KEY (root on production) to its rollback step. Any install script in the npm tree ran first in the environment the key was later given to.

The rollback is now rollback-production-on-e2e-failure: a job of its own, needing e2e-prod, and keyed on the test step's outcome exported as a job output rather than on the job's failure(), so a failed checkout, npm ci or Chrome report still leaves production alone. It is gated on !cancelled() rather than always(), so cancelling the run still stops it, as it stopped the in-job step. It takes production-deploy like the deploy-failure rollback it is modelled on, so a following run's deploy cannot be in the deploy directory while rollback.sh is. Its ssh-action is pinned to the same commit as every other use of that action, and it keeps the workflow's read-only token.

test_deploy_workflows.py now holds every workflow to the rule that no job that installs packages, runs in a container or starts service containers can read a droplet key, and pins the new job's condition across refs, events and test outcomes. Checked against main's ci.yml, where the rule fails on e2e-prod.

ClickUp 123zgec4mze.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Babissimo
Babissimo force-pushed the ci/prod-e2e-rollback-job branch from cd278f4 to ebedabb Compare September 28, 2026 12:18
@claude

This comment has been minimized.

@Babissimo

Copy link
Copy Markdown
Contributor Author

Rebased onto main at ad12bbd, over #644 (the E2E jobs moved from Playwright's container to the runner's Chrome) and #651 (the Chrome report in each E2E job). Head: ebedabb.

What that changed here:

  • e2e-prod keeps main's new shape: checkout, setup-node, npm ci, the Chrome launch, then the tests, with no container. The description's references to the image, to running "on the runner" by contrast with it, and to a failed pull no longer apply. The job output now separates the test step's verdict from a failed checkout, npm ci or Chrome launch, and the commit message says so.
  • Read Chrome's version in the staging E2E job instead of launching it #651's reason for launching Chrome in production, keeping a Chrome that cannot start away from the rollback, holds as written: a failed launch leaves tests as skipped, so the rollback job does not run.
  • The rule in test_deploy_workflows.py is unchanged. It still fails on main's ci.yml at e2e-prod, which still runs npm ci in the job that hands the rollback its key.

Verification on this head: the workflow, pin, permission and deploy-workflow tests and their neighbours pass (313), run under the shared suite lock, and pre-commit, actionlint included, is clean.

@Babissimo

Copy link
Copy Markdown
Contributor Author

On the review of ebedabb:

  1. The unlocked smoke and E2E jobs are the limit the description defers to Hold the production lock across production's verification and rollbacks #648, which runs production's whole chain under one calling job holding production-deploy.
  2. The comment on the gate claims only that an operator's cancel of the run stops the rollback, which is what cancelled() reports: whether the run was cancelled. It makes no claim about e2e-prod timing out. That case is covered by the output check, as the review says: a timed-out or cancelled test step has the outcome cancelled, never failure, so the rollback stays off.

No change needed on either.

@claude

claude Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Claude finished @jehanazad's task in 10m 31s —— View job


Claude's review of PR #590

Summary

Reviewed ci.yml's new rollback-production-on-e2e-failure job and the accompanying test_deploy_workflows.py guard across correctness, cross-file tracing, and the repo's CI/workflow conventions. The job itself checks out as intended: it's a genuinely separate job (no container/services), gated on needs.e2e-prod.outputs.tests == 'failure' (not the job's own result, so a failed checkout/npm ci/Chrome launch in e2e-prod correctly leaves tests unset and skips the rollback), takes the production-deploy concurrency group before rollback.sh runs, and pins appleboy/ssh-action by SHA. I traced !cancelled() against both an operator cancel and an e2e-prod job-level timeout and both resolve the way the inline comment at ci.yml:453-456 claims. The new test_no_job_that_runs_third_party_code_can_read_a_droplet_key guard correctly fails against main's ci.yml (confirming the test is real, not vacuous) and I checked it against every other job holding an *SSH*KEY* secret across ci.yml, staging-deploy-verify.yml, and deploy-test.yml — none produce a false failure.

One thing worth a maintainer's attention, nothing else survived scrutiny of the diff:

Findings

  1. backend/tests/test_deploy_workflows.py:220 — _allowed()'s field-boundary regex doesn't exclude a following ., so a field name that is a dotted prefix of a longer field referenced in the same condition would get corrupted instead of left alone.
    condition = re.sub(rf"(?<![\w.-]){re.escape(field)}(?![\w-])", repr(value), condition)
    The trailing lookahead (?![\w-]) blocks a following word-character or hyphen (so needs.e2e-prod.outputs.tests won't accidentally match inside ...tests_summary), but it doesn't block a following .. If some future rollback if: condition ever referenced both a field and a longer field that extends it with a dot (e.g. a nested outputs.tests.something), substituting the shorter field first would splice the substituted literal into the middle of the longer reference — producing an eval SyntaxError or a silently wrong boolean, defeating the very security-gate test this helper backs.
    This isn't live today: the only fields currently passed to _allowed() (github.ref, github.event_name, needs.deploy-production.result, needs.e2e-prod.outputs.tests) don't collide this way, so none of the 72+ parametrized cases are affected. It's a latent robustness gap in the test helper rather than a bug in this PR's shipped behavior. Tightening the lookahead to (?![\w.-]) would close it for future conditions at no cost to the current ones.
    Fix this →

The disclosed gaps in the PR description (no lock held by production-smoke-tests/e2e-prod, the actions/runner#2598 re-run caveat) are pre-existing/explicitly deferred to the next PR in the stack, not new regressions here.

Branch ·

@jehanazad
jehanazad merged commit 85a487c into main Oct 1, 2026
21 checks passed
@jehanazad
jehanazad deleted the ci/prod-e2e-rollback-job branch October 1, 2026 00:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants