Skip to Content
ReferencePlatform repo layout

Platform repo layout

Your platform repo is the GitOps repo your ArgoCD watches. TruStacks proposes changes to it as pull requests. This page shows what lands where.

What lands depends on the kind of the Application’s deployment target. A kubernetes target (the default) gets a Helm chart or Kustomize overlay and an ArgoCD Application. An ecs-fargate target (Beta) gets a task definition and a deploy workflow instead; see ECS Fargate layout. Every kind gets the same reusable CI workflow.

Allowed paths

Every file in a proposal must live under one of three prefixes. The constitution rule proposal.allowed_paths refuses anything else, and proposal.no_path_traversal refuses absolute paths and ...

gitops/ deploy artifacts (Helm chart, Kustomize base + overlays, or an ECS task definition and its binding) argo-apps/ ArgoCD Application manifests, one directory per cluster .github/workflows/ the reusable CI workflow, one per service, and for ECS Fargate targets a deploy workflow per service per target

environment-profile.yaml also lives at the repo root. TruStacks reads it but never proposes changes to it. That file is yours.

Helm layout

One complete chart per cluster per service:

gitops/<application>/<cluster>/<service>/ ├── Chart.yaml ├── values.yaml # image.repository and image.tag └── templates/ ├── deployment.yaml └── service.yaml

There is no _helpers.tpl. Charts use built-ins (.Release.Name, .Chart.Name, .Values) so they render with only these four files.

Kustomize layout

One shared base and one overlay per cluster:

gitops/<application>/<service>/ ├── base/ │ ├── kustomization.yaml │ ├── deployment.yaml │ └── service.yaml └── overlays/<cluster>/ └── kustomization.yaml # namespace, and the image tag via images:

The image tag appears only in the overlay. ArgoCD points at the overlay, not the base.

Kustomize is in Beta. It is checked by kustomize build before every proposal, but it has not yet run end to end against a customer platform repo. Tell us what you find.

The renderer is set per Application (Applications → your Application → Renderer). An Application can also override the root path template; the defaults above apply otherwise.

ArgoCD Applications

argo-apps/argo-apps-<cluster>/<service>-application.yaml

Each Application:

  • points spec.source.repoURL at your platform repo, exactly (rule argocd.repoURL_is_canonical);
  • points spec.source.path at the chart (Helm) or the cluster’s overlay (Kustomize);
  • sets syncOptions: [CreateNamespace=true];
  • has no automated sync. Merging a pull request does not deploy; someone syncs in ArgoCD. When the proposal’s target cluster has tier prod, whatever it is named, the constitution enforces this (practice.argocd_prod_requires_manual_sync). Runners older than 0.2.14-rc25 do not send the tier, so for them the rule falls back to a cluster literally named prod (argo-apps/argo-apps-prod/).

To have ArgoCD pick these up, apply one root Application of your own that points at argo-apps/. If the platform repo is private, ArgoCD needs read credentials for it. See GitOps integration.

The CI workflow

The pipeline is a reusable workflow in the platform repo, one per service:

.github/workflows/ci-<service>.yaml # on: workflow_call, nothing else

It has two jobs, test and build. build pushes the image the chart (or the ECS task definition) references. It triggers only through workflow_call, never on push or pull request, because it builds your service, not the GitOps repo it is stored in.

The caller in your service repo

Add this once to each service repo. It is the only pipeline file you write.

# .github/workflows/ci.yaml in the SERVICE repo name: ci on: push: branches: [main] # your default branch pull_request: jobs: trustacks: # The called workflow pushes an image. Pull requests from forks get a # read-only token and cannot push, so skip them instead of failing. if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository uses: <owner>/<platform-repo>/.github/workflows/ci-<service>.yaml@<commit-sha> permissions: contents: read packages: write

Pin <commit-sha> to a platform-repo commit you have reviewed, such as the merge commit of the TruStacks pull request that added the workflow, and move the pin when you merge a change to it. @main also works and follows every merge, at the cost of trusting whoever can push to that branch. The proposal’s summary includes the snippet with your names filled in, in the shorter @main and on: [push, pull_request] form; prefer the one above.

Private platform repos

A private repo shares its reusable workflows with nothing by default. The caller then fails before running a step, with an error saying the workflow was not found, which points away from the real cause. Grant access once:

PLATFORM_REPO=your-org/your-platform-repo # owner/name of your platform repo # Platform repo owned by an organization: gh api --method PUT \ "repos/$PLATFORM_REPO/actions/permissions/access" \ -f access_level=organization # Platform repo owned by a personal account: gh api --method PUT \ "repos/$PLATFORM_REPO/actions/permissions/access" \ -f access_level=user

Public platform repos need nothing.

ECS Fargate layout (Beta)

For a deployment target whose kind is ecs-fargate, TruStacks emits three files per service, per target, plus the same reusable CI workflow as above:

gitops/<application>/<target>/<service>/ ├── task-definition.json # the workload: image, user, hardening, port, cpu/memory, logging └── deploy-target.json # which AWS account, region, roles, cluster and service (yours to fill in) .github/workflows/deploy-<service>-<target>.yaml # applies the merged task definition
⚠️

ECS Fargate is in Beta. It is validated offline and gated by the constitution, and its end-to-end path is tested with a stubbed model, but no customer has run it against a real AWS account yet. Tell us before you rely on it, and tell us what you find.

The GitOps root must contain {cluster} (the target name), so two targets never share one task definition. A Kustomize-shaped root is refused with a message rather than silently replaced.

The task definition

Built from a hardened skeleton the agent adapts within (port, cpu and memory, non-secret environment, CPU architecture) and never strips:

  • requiresCompatibilities: ["FARGATE"] and networkMode: "awsvpc";
  • a non-root user, taken from the framework pack’s Dockerfile, otherwise the distroless nonroot UID 65532;
  • readonlyRootFilesystem: true, with a writable tmp volume mounted at /tmp;
  • linuxParameters.capabilities.drop: ["ALL"] and initProcessEnabled;
  • awslogs logging in non-blocking mode, to a log group named in the file;
  • the image from your declared registry, tagged with the service commit SHA.

The family is <application>-<service>-<target>. Role ARNs and the log region are not in the committed file; the deploy workflow binds them from deploy-target.json.

TruStacks does not render the image at deploy time. The committed task definition carries the image, and the deploy applies exactly that file, so what runs is what was reviewed in Git.

The binding: deploy-target.json

Facts about your AWS account, committed next to the task definition so that retargeting a deploy is a reviewed pull request, not an edit in GitHub settings.

KeyWhat it is
aws_account_idThe 12-digit account the target lives in. The deploy refuses any other.
aws_regionThe region of the ECS cluster (also the log region).
deploy_role_arnThe IAM role GitHub Actions assumes through OIDC to deploy this target.
ecs_clusterThe ECS cluster name or ARN.
ecs_serviceThe existing ECS service this task definition updates.
execution_role_arnThe task execution role (image pull, logs, secret retrieval).
task_role_arnOptional. The role your application runs as.

It arrives blank, and you fill it in. TruStacks writes the skeleton only when the file does not exist yet, and never when it could not read your platform repo, so a binding you filled in is never overwritten. Every value is yours to set: a value we filled in would be a claim about your account that you never made. Until it is filled in and merged, the deploy fails and names the missing key and the file.

The deploy workflow

deploy-<service>-<target>.yaml runs on a push to main that touches that target’s task definition or binding, and on manual dispatch. Merging the pull request is the deploy. The governed trigger is the reviewed merge, the same way ArgoCD watching main is for Kubernetes.

What it does:

  • runs with permissions: {} at the top, and contents: read plus id-token: write on the job; actions/checkout with persist-credentials: false; one deploy at a time per target;
  • refuses to deploy a commit that is not the head of main, so re-running an old run cannot roll back outside Git;
  • reads the binding, refusing a blank required key or a value with characters outside [A-Za-z0-9:/_.@+=,-];
  • authenticates to AWS with OIDC: the role from the binding, allowed-account-ids pinned to your account, and a session name carrying the run id;
  • binds the roles and region into a temporary copy of the task definition and writes its SHA-256 to the job summary;
  • warns, without failing, when your ECS service has no deployment circuit breaker with rollback;
  • deploys with aws-actions/amazon-ecs-deploy-task-definition and waits up to 20 minutes for the service to stabilise, inside a 30-minute job timeout.

Every deploy job declares environment: <target> on every tier. Your deploy role’s trust policy pins to it.

Prod on a Fargate target. Merging deploys, so the pull request review is the human approval of a promotion to prod. On a prod-tier target the constitution denies a deploy job without an environment: (practice.workflow_prod_deploy_requires_environment). That makes a second gate possible: required reviewers on that GitHub environment hold the deploy until someone approves it. TruStacks reads the workflow, not your environment’s protection rules, and on private repositories required reviewers depend on your GitHub plan (at the time of writing, Enterprise Cloud). So for a Fargate Application, manual_promotion_to_prod scores as claimed at most, from your own attestation, never verified.

Every action is pinned to a full commit SHA curated in the target-kind pack: actions/checkout v6.1.0, aws-actions/configure-aws-credentials v6.3.0, and aws-actions/amazon-ecs-deploy-task-definition v2.6.3.

Rollback is a revert pull request. The revert touches the task definition, so merging it redeploys the previous one.

What you provide in AWS

TruStacks does not create AWS infrastructure. The pull request lists these as prerequisites; you create and run them:

  • The ECS cluster and an existing ECS service. The deploy updates a service; it never creates one. Turn on the service’s deployment circuit breaker with rollback, or a failed deploy will not roll itself back.

  • The IAM OIDC provider for GitHub, and a deploy role whose trust policy pins more than sub: aud, token.actions.githubusercontent.com:repository_id and repository_owner_id, ref = refs/heads/main, job_workflow_ref = the deploy workflow at refs/heads/main, and environment. The ID-based keys matter for repositories created or renamed after 2026-07-15, whose subject claims no longer match the repo:<owner>/<repo>:environment:<env> form.

  • The deploy role’s permissions, scoped tightly:

    PermissionResourceCondition
    ecs:RegisterTaskDefinitiontask-definition/<family>:*ecs:privileged = false
    ecs:UpdateService, ecs:DescribeServicesthe one service ARNUpdateService conditioned on ecs:task-definition
    iam:PassRolethe execution and task roles onlyiam:PassedToService = ecs-tasks.amazonaws.com
  • The task execution role: image pull, logs:CreateLogStream and logs:PutLogEvents, secretsmanager:GetSecretValue and ssm:GetParameters only on the secrets you reference, and kms:Decrypt for customer-managed keys.

  • The CloudWatch log group named in the task definition, created by you with the retention you want.

  • If your app writes to /tmp: a Fargate bind mount starts root-owned, so your image must create /tmp owned by the runtime user and declare VOLUME ["/tmp"].

Honest limits

  • Tags are not image identity. The task definition carries a commit-SHA tag, and a tag can be overwritten by anyone who can push to your registry. ECS resolves the tag at deploy time. Until deploys pin a digest, what TruStacks can claim is a reviewed tag and a reviewed task definition, not a reviewed image, and reverting a task definition does not guarantee the bytes that ran before.
  • No continuous reconciliation. ECS has no equivalent of ArgoCD’s sync loop. A change made outside Git (for example aws ecs update-service) goes unnoticed until the next merge.
  • No deploy status yet. The read-only deploy observer watches ArgoCD only, so a Fargate proposal shows merged, not healthy.
  • Scoring is still Kubernetes-shaped. A Fargate-only Application still gets Kubernetes rows such as gitops_controller in its gap report, and only one Fargate rule (ecs_task_runs_as_nonroot) feeds the score. Per-kind scoring is planned.
  • Not in scope: Terraform, CDK or CloudFormation for your clusters, services, networking, IAM or log groups; setting up your AWS account; Lambda, App Runner, ECS on EC2 and other targets.

Your edits are kept

Before writing any file, the DevOps Engineer reads the existing version from your platform repo and keeps what you added: an extra job, a tuned value, a patch. Each pull request lists what was kept, replaced, or removed under Preservation notes.

An agent may delete a file only when the same proposal adds its replacement, and the pull request says so.

Where to go next

Last updated on