Intro

Deployment

How DevStride actually ships to dev and prod: a merge is the deploy, through Seed. The one deploy command, ds seed deploy, is for a deliberate redeploy.

Deployment

Merging is the deploy. There is no ds deploy and no pulumi up in the normal flow: deploys are triggered by pushing to a tracked branch and orchestrated by Seed, not by GitHub Actions. The one exception is ds seed deploy, which asks Seed to redeploy a chosen commit to a stage with the team's shared token. It can deploy production, so it is never run against prod without the owner's yes.

Seed (seed.run) drives every deploy

The repo's seed.yml defines the build hooks that Seed runs on every push to a tracked branch. Stripped of the toolchain pinning (Node, Corepack, the pnpm store path), the hooks that matter are:

before_build:
  - bash ds -ru script generate-api-docs
before_deploy:
  - bash ds -bru script set-config
  - bash ds -ru script delete-orphan-stacks
  - bash ds -ru script check-orphan-stacks
  - bash ds -ru migrations run-sql-expand
after_deploy:
  - bash ds -ru script sync-identity-providers
  - bash ds -ru migrations run-sql-contract
  • before_build regenerates the published OpenAPI spec so it matches the routes being deployed.
  • before_deploy pushes the environment's config into the SST DEVSTRIDE_CONFIG secret (-b forces a fresh bind, so generate-once secrets keep the value currently deployed rather than a cached snapshot), deletes and then guards against orphaned CloudFormation stacks left behind by stacks removed from sst.config.ts, and applies the expansion migrations — additive schema changes both the old and the new Lambda revisions can live with during the rolling multi-stack deploy.
  • after_deploy runs as a separate Seed build process on a cold cache. It first restores Google sign-in on the Cognito app client (CDK resets the provider list on every deploy; see sync-identity-providers), then applies the contract migrations — the destructive or tightening changes that are safe only once every new Lambda is live. The order is deliberate: a contract migration that fails (a lock timeout on a busy table) stops the process and rolls back to run again on the next deploy, and must never leave Google sign-in switched off with it.

This split is why migrations must be idempotent and rolling-deploy safe: for a few minutes the old code runs against the expanded schema, and an event emitted by the old revision can be consumed by the new one. Locally, ds migrations run applies both chains at once. None of this happens on your machine — it is the exact sequence Seed executes on every deploy.

GitHub Actions: CI checks, not deploys

It's easy to assume .github/workflows/ is where deploys live, since that's the conventional place for them. In this repo, it isn't. All ten workflow files run checks or maintenance jobs — none of them deploy anything. Most run on pull requests; the expensive ones do real work only when a change is on its way to production:

WorkflowTriggerWhat it does
backend-tests.yamlPull requests; pushes to develop and masterRuns the sharded backend vitest suite, but only where it adds coverage — see below
backend-tests-manual.yamlManual dispatch onlyRuns the full backend suite on demand against any pushed branch — every shard, no filters. It calls backend-tests.yaml rather than repeating it, so its results appear under Backend Tests (manual) / … and can never stand in for a pull request's own verdict — see below
ci-policy.yamlPull requestsEnforces the draft-first convention: a pull request a person opens straight to ready fails this check, with the convention explained
lint-backend.yamlPull requests into master; pushes to masterType-checks the backend (pnpm run check:ts), then runs the infrastructure synthesis tests and the dead-letter-queue wiring checks
lint-frontend.yamlPull requests into master; pushes to masterType-checks the frontend (pnpm run check:ts), then runs eslint, errors only (pnpm run lint:errors) — the backstop for lint errors the local runs missed. Warnings never fail it
ui-tests.yamlPull requests into master; pushes to masterRuns the frontend test suite
merge-path.yamlPull requests into develop, drafts includedPosts the required merge-path check: only an epic integration branch, the support train or its release snapshot, a master-into-develop sync, a release, hotfix or Dependabot branch may merge into develop, unless the pull request carries merge-path-override. It reads its rules and checker from develop itself, so a pull request cannot edit its own gate. ds merge-path check gives the same verdict locally — see Command Reference
mcp-tests.yamlPull requests that touch the MCP server (packages/mcp) or its install inputs; manual dispatchType-checks and tests the DevStride MCP server. Not a required check
golden-republish.yamlPushes to master; manual dispatchReconciles the canonical golden dataset source after each release — migrate, re-certify, a few minutes on the standard runner. A full rebuild is local-only: the workflow opens a golden-refresh-failure issue for the operator when one is needed
claude.ymlIssue/PR comment containing @claudeThe Claude Code PR-comment bot

Linters and UI tests run on releases, not on every merge

lint-backend, lint-frontend and ui-tests do their work only on a pull request into master — the release pull request from a release/YY-MM-DD branch, or a hotfix — and on the push to master that lands it. A push to develop runs none of them, and on a pull request into develop or an epic branch their jobs report skipped, which still satisfies the required checks, so the merge is not blocked. In the cloud, a lint, type or frontend-test break is therefore found on the release pull request, the same way a backend-suite break is — but every pull request into develop has already run the same checks locally, as the required local-ci check.

The push to master that merges a release does not repeat them: it skips only when it can find a successful run of the same job on the commit being promoted, so a direct push or a hotfix to master is still checked.

One part of lint-backend cannot wait for the release: SEED deploys develop on every merge, so a stack that names a missing handler or queue would break the shared dev deploy long before a release pull request saw it. The infrastructure synthesis tests and dead-letter-queue checks therefore also run locally, as the infra-synthesis pre-ship check, on any pull request that touches stacks/** or deploy configuration.

local-ci: the required check on develop

Because the cloud runs so little on a pull request into develop, the deep checks run on the team's own machines instead. Every pull request into develop must carry a green local-ci commit status before it can merge. It has been a required check on develop since 2026-10-04, alongside merge-path.

local-ci is posted by ds verify full --post, which runs the full local mirror of cloud CI on the pull request's exact head: a frozen-lockfile install, every type-check, eslint over the frontend (errors only), the MCP and frontend tests, the UI build and portal budget, a readiness check on the test database, the whole non-golden backend suite, and the pre-ship checks the change triggers. It takes about half an hour, and it posts success only when every step passed on that commit with a clean tree. The delivery loop runs it before it marks a pull request ready.

Every kind of pull request into develop can get one:

  • An epic release, the support train, or an infrastructure one-off runs it in full.
  • A master-to-develop sync after a release adopts the release pull request's green cloud run, in seconds, when the two trees are identical (--adopt-release). The status names the run it adopted.
  • Dependabot updates are folded into one dependabot/batch-<YY-MM-DD> branch by ds work dependabot-batch and checked once, rather than once per update.

Below it, every Story and one-off has already passed ds verify land before it merged onto its Epic's branch or the train: every type-check, eslint on the changed frontend files, and every test whose imports reach the changed code.

Why. Before, a pull request into develop ran almost nothing in the cloud, so a break in the linters, the UI tests or the backend suite was first found on release day, with the whole release waiting on it. Running the same checks locally, on machines the team already has, finds it before the merge and costs nothing in CI minutes. The release pull request into master keeps its cloud run as the last line of defence.

The backend suite runs once per release, not on every merge

The backend suite is by far the most expensive thing in CI, so it runs only where it adds coverage:

  • On the release pull request into master (from the release/YY-MM-DD branch cut from develop) — once per release.
  • On the push to master that merges it, the suite is not repeated. Each run records the exact code it tested, and the push reuses the release pull request's green result when that record matches what is being merged. Anything missing, unreadable or different, and the suite runs.
  • On a hotfix into master whose code was not already tested green.
  • On any pull request carrying the run-backend-ci label, when it is opened, reopened, edited or marked ready — not on later pushes. To prove new commits, convert the pull request back to draft and mark it ready again.
  • On a push to develop only when the repository variable BACKEND_TESTS_ON_DEVELOP_PUSH is true. It is off by default, so an ordinary merge into develop runs no shards.
  • Whenever you ask for it, against any pushed branch:
    gh workflow run backend-tests-manual.yaml --ref <branch>
    

    or the Run workflow button on the Actions tab. A manual run always runs every shard — no path filter, no reuse of an earlier result — because you asked for exactly that. There is deliberately no ds wrapper around it: the one-line command above is the whole feature. GitHub only offers workflows that exist on the branch you pick, so a branch cut before this workflow landed has to be rebased on develop first.

Everywhere else the required Backend Tests check reports skipped, and the reason is printed in the "Detect backend changes" job beside it, which is green. A required check is satisfied by a successful, skipped or neutral result, so a skipped check still merges — what it no longer does is look identical to a run that tested something.

So the check now tells you which happened:

  • Green — the suite ran here and passed, or this run adopted a result that already covers exactly this code: a run on the same commit from a pull request into master, or the release pull request's green run reused by the push that merges it. The reason is printed either way.
  • Skipped — this run tested nothing, by the rules above.

The trade-off is deliberate. The cloud would find a merge that breaks the suite only when the release is cut, but the whole suite has already run locally on every pull request into develop, as part of the required local-ci check, and every Story ran the tests that reach its changes before it merged. When you want the cloud suite over a branch sooner than that, the manual run above is the way to get it.

Deploy workflow files did exist here once, but they were deleted years ago — they're simply gone, not present-but-defunct. Today, GitHub Actions is a checks-and-maintenance pipeline — pull-request gates, plus the comment bot and the golden dataset reconcile that follows each release push — while the deploy pipeline lives entirely in Seed's own infrastructure.

Branch → stage mapping

By convention:

  • Push/merge to develop → deploys the dev stage, served at app.devstride.dev
  • Push/merge to master → deploys the prod stage, served at app.devstride.com

The frontend ships with the backend

The frontend isn't deployed by a separate pipeline. It ships as part of the same SST deploy, via the sst.StaticSite construct (stacks/ui/app-ui.stack.ts) — built with pnpm run build:ui and pushed to S3/CloudFront in the same sst deploy that ships the backend Lambdas.

That stack is gated off when you're running locally: sst.config.ts only registers the SiteStack when !app.local, so ds run backend (which runs pnpm exec sst dev) never tries to build or deploy the frontend — for local frontend work you run ds run ui separately, which just points a local Vite dev server at your bound stage's API.

Manual escape hatch (advanced, narrow)

There are exactly two documented cases for invoking the SST CLI directly instead of going through CI. The first is scoped to the packages/mcp package specifically:

DEVSTRIDE_STAGE=prod pnpm exec sst deploy

packages/mcp/README.md documents this to ship the MCP server, noting explicitly that it's part of the same SST deployment with no separate pipeline of its own — this is a raw sst invocation, not wrapped by any ds command. Treat it as an advanced, narrow escape hatch for that package, not a general-purpose way to deploy the app. Similarly, the root README.md documents raw sst remove for tearing down your own personal stage — again a direct SST CLI call, not a ds subcommand.

Outside of these two narrow, explicitly-documented cases, the only supported path to a deployed dev or prod environment is: merge to develop or master and let Seed do the rest.

Next Steps