eDossea · delivery pipeline
How a commit becomes a HIPAA estate
A green pipeline is not evidence. This one is built to produce the proof, and every claim in this deck is a line it actually printed.
21 August 2026 · account 027401589926 · run 32499130045
the chain, end to end
Two repositories, one account boundary
The application builds images and says so. The infrastructure repository decides everything else. Both halves run: the publish workflow is exercised from J2-Labz/edossea-copy, which pushed four images and dispatched a deploy that put its own digests into dev. The product repository keeps the change under review, and its own subject is trusted separately.
push-only
Prod is not a second build. It receives dev's manifests copied across by digest, so no CI outside the PHI account ever holds write access to its registry.
the invariant
“Deploy to prod exactly what we deployed to dev”
Three readings. Only one of them is achievable, and being precise about which one is what makes the promise real instead of reassuring.
The same commit
Achievable, and it is the mechanism.
One workflow, chained with needs:, so every job in a run sees
the same github.sha however far main moves meanwhile.
The same Pulumi plan
Impossible, and correctly so. A dev plan
contains dev account ARNs and edossea-dev-* names. It cannot be
applied to prod, because the two estates are deliberately separate
resources.
The same resulting shape
Already guaranteed — by the catalogue that declares each environment's services, database and cache, not by this pipeline.
enforcedprod: Assert prod is deploying the commit dev deployed
enforcedprod: Assert prod deploys the application version dev deployed
The re-check exists because re-running prod alone is the one path where the chain cannot carry the commit for you.
the gate — before any aws call
Everything that can fail without a credential
- typecheck
- 362 unit tests, two estates
- control-absence linter
- security-group descriptions
- phase and destroy ordering
- OIDC trust subjects
- CloudFormation templates
- database seeding path
- workflow lint
- composite-action shell lint
- compliance matrix staleness
- offline
pulumi preview
measured# tests 58 · pass 58 · fail 0
# tests 304 · pass 304 · fail 0
It runs on every pull request
Before that, the gate first executed after a merge — on main, where a failure is a red default branch and a pass continues straight into a deploy. A gate whose first run is post-merge has already let the change through.
Each check tests itself, both poles
Every checker plants defects into a copy of the tree and asserts each one is caught. A check that has only ever reported success is a claim about its inputs, not about the repository — three of these were false-clean when first written.
the catcha pull_request event leaves inputs.environment EMPTY — so `!= 'prod'` was true, and a PR would have deployed into the dev account
credentials
No key ever exists to be leaked
A runner asks GitHub for a short-lived assertion about itself and trades it for an AWS session. The trust policy names one repository and one GitHub environment — not a branch.
Why the environment, not the ref
A job that declares environment: presents
…:environment:dev, and the ref: form never appears
at all. Pinning the environment is also stronger: a ref pin says “this came
from main”, an environment pin says “this came from a job that entered
prod”.
The prefix is a per-repository fact
GitHub may emit an immutable prefix carrying numeric org
and repo ids. Both repositories report use_default: true and
emit different forms, so it cannot be inferred — only read
back from the issuer.
| repository | subject prefix it sends |
|---|---|
| edossea-iac | repo:J2-Labz@207084436/edossea-iac@1306994950 |
| eDossea | repo:J2-Labz/eDossea |
foundthe bootstrap composed the NAME form from $GITHUB_REPOSITORY — re-running it would have repointed the live trust at a subject GitHub never sends
nowevery deploy prints the subject it presents, one line above the assume that needs it
the applies
Seven passes over six stacks
The repetition is the honest form of a cycle
platform-data needs a security group from
app-platform, and app-platform needs the database
endpoint back. Two passes state that plainly instead of hiding it.
One stack is kept out on purpose
dns-delegation writes into the company's
real domain, where Google Workspace mail also lives. A Cloudflare token
cannot be scoped to record names — only to a zone — so the
credential that writes four NS records could rewrite all of them. Tearing
down an environment must not be able to reach that, so the stack that can is
not part of an environment, and its token never enters CI.
deriveddestroy order = the reverse of each phase's FIRST occurrence — never written out by hand
guards
Three ways a routine deploy can destroy something running
Each of these is a real incident, not a hypothetical. The fix in every case is that the guard asks AWS, because a guard whose subject is the cloud cannot have the repository as its source of truth.
A fabric-only deploy over a live service
Deploying with no application version once recomputed the first-deploy marker and deleted the ECS service, its task definitions, its role and its egress rules — 19 resources.
Now: ecs describe-services,
with three outcomes — a missing cluster fails, a missing service returns an
empty list, and only one of those means “fresh environment”.
A config key that names a live resource
Removing two keys would have planned a delete of the CloudFront distribution serving dev, because the code that reads the replacement key does not create the distribution when it is absent.
Now: ask CloudFront for the exact comment the brick composes, and refuse.
A seeder that rotates a live secret
“Seed secrets if absent” wrote unconditionally and irreversibly rotated three live application keys.
Now: a three-outcome check — has a value, has none, or could not be read — and only the middle one writes.
the lessonthe first version of the ECS guard grepped the repository, and dev's committed config recorded imageUri: "" — so it failed OPEN on the one live environment it existed to protect
after every deploy
Why a 200 from the API proves nothing
The distribution rewrites 403 and 404 to index.html with status 200
Correct for a single-page app, and it means a broken API
is indistinguishable from a healthy one by status code. A
desynchronised cache token once left the API in a reconnect loop while
/api/health answered 200 with HTML, and both existing checks
passed.
Six in a row, not one
With two tasks behind a load balancer, one broken task answers about half the requests — so a check that exits on its first success passes every time. A retry loop is for waiting on something starting; it must not double as the verdict.
and yethealth answered 6/6 and login returned 200 while every authenticated GET hung — the task had no egress rule to the cache, so Redis timed out forever
assertedcontent type and payload, never the status
measured6/6 consecutive JSON responses
{"success":true,"data":{"status":"ok"}}
assertedno route off the VPC — the one control with no runtime rule in any benchmark, so the pipeline reads the live route tables and treats a non-zero count as an incident
measuredok email [email protected] (confirmed)
That last one was SubscriptionArn: "Deleted" until this week —
six security alarms reaching nobody. A confirmed subscription is still not
proof: delivery was established by publishing a probe and reading the mailbox.
exercising it before merge
The deploy path runs from a branch, with no approval
workflow_dispatch runs the workflow file
from the ref you name — so a branch's own pipeline executes
against the real dev estate before anything is merged.
It works because the trust is ref-independent
A job declaring environment: dev presents
the same subject whatever branch it came from, so nothing has to be widened
for a test run. Pinned to ref:refs/heads/main instead, this
would be impossible.
Mirroring the repository is the wrong answer
A mirror has a different repo id, so it presents a different subject — the trust policy would have to be widened to admit it, and the run would measure a relationship production does not have. It also puts the code outside the organisation's ownership and audit, which is not undoable.
| run | what it proved |
|---|---|
| fabric only | assume from a feature branch; the live service survived the shape that once deleted it |
| app version | four images by digest, schema applied, frontend published, API 6/6 |
| pull request | gate ran; both deploy jobs reported skipped |
caveata NEW workflow file must land on the default branch before its first dispatch — edits to an existing one do not
caveata dispatch deploys the REAL estate, so every destructive guard applies — which is the point
where it stands
Running today, and the next link to close
| piece | state |
|---|---|
| Gate on every pull request | running |
| OIDC credential path, dev | running |
| Seven applies, dev estate | running |
| Schema task, frontend publish | running |
| Post-deploy assertions | running |
| Alarm delivery to a human | proven |
| Application publish workflow | running from the copy |
| ↳ in the product repo | open for review — eDossea #626 |
edossea-app-publish role | created, push-only |
AWS_PUBLISH_ROLE_ARN variable | set on the copy |
| Prod account bootstrap | not started |
The next link, precisely
Every piece is written. The infrastructure side listens
for repository_dispatch: app-published, the AWS grant is
templated, and the publish workflow itself is open for review as
eDossea #626 — it assumes the push-only role by OIDC, pushes
four images, then dispatches.
What is missing is two facts, not code: the role does not
exist yet (NoSuchEntity), because the bootstrap has never been
run, and AWS_PUBLISH_ROLE_ARN — which that run prints — is not
set in the application repository. Run the bootstrap, set the variable, merge
#626.
Until then the chain is still testable
Dispatching with an existing image tag exercises promote, schema, frontend and service — no application credential involved. That is how the second run above was produced.
What the first real login found
Registering an account through the application's own flow exposed a defect nothing else could: the task had no egress rule to the cache, so every request touching Redis timed out. The cache declared ingress from the application — a security group is two-sided, and only one side existed. Health checks and login never touch Redis, so the fleet read healthy for as long as nobody logged in.
livehttps://compliant-dev.edossea.com