> ## Documentation Index
> Fetch the complete documentation index at: https://docs.formae.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Run formae in CI/CD

> Drive formae non-interactively from a pipeline: validate changes in a pull request, apply on merge, pass values between stages, and manage the pipeline itself as a forma.

formae is built to run unattended. The same `formae apply` you run from a laptop
works in a CI/CD job once you turn off the interactive prompts and read
structured output instead of tables. This guide covers the non-interactive
contract, the simulate-in-a-pull-request then apply-on-merge flow, passing
values between stages, worked GitHub Actions and GitLab CI pipelines, reading
discovered resources, and fanning the same pipeline out across many
repositories.

The [Git as the source of truth](/documentation/concepts/ways-to-work) operating
model is the natural fit here: your forma files live in Git, the pipeline is the
only thing that applies them, and every change lands through a reviewed merge.

## Run formae non-interactively

A pipeline runner needs the formae CLI and a way to reach the
[agent](/documentation/guides/install-agent). The agent is long-lived and shared:
you do not start one per job. Many pipelines and humans connect to the same
agent at once.

Install the CLI in the runner the same way you would locally:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
/bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)"
```

<Note>
  The runner has to reach the agent. The `default`
  [profile](/documentation/guides/manage-profiles) points at
  `http://localhost:49684`, which is only right when the agent runs on the same
  host. For a remote agent, add a profile whose `cli.api.url` is the agent host and
  select it per command with `--profile`, or point the CLI at the agent through
  your CI secrets.
</Note>

Three flags turn `apply` into something a pipeline can drive:

| Flag                                             | Purpose                                                                                                                                                                      |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--mode reconcile` or `--mode patch`             | Required on every apply. Reconcile makes the stack match the forma exactly; patch applies only the changes you name. See [Apply modes](/documentation/concepts/apply-modes). |
| `--yes`                                          | Run without the interactive confirmation prompt.                                                                                                                             |
| `--output-consumer machine --output-schema json` | Emit JSON instead of human tables so later steps can parse the result.                                                                                                       |

### Gate the pipeline on the outcome

`apply` submits the command to the agent and the agent executes it
asynchronously. In machine mode, `apply` prints the command ID as soon as the
command is accepted:

```json theme={"languages":{"custom":["/languages/pkl.json"]}}
{ "CommandId": "2a1f..." }
```

To make the job pass or fail on the actual result, capture that ID and poll
`formae status command` until the command reaches a terminal state:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
CMD_ID=$(formae apply --mode reconcile --yes \
  --output-consumer machine --output-schema json infra/main.pkl \
  | jq -r '.CommandId')

while :; do
  STATE=$(formae status command --query="id:$CMD_ID" \
    --output-consumer machine --output-schema json \
    | jq -r '.Commands[0].State')
  case "$STATE" in
    Success)          echo "apply succeeded"; break ;;
    Failed|Canceled)  echo "apply $STATE"; exit 1 ;;
    *)                sleep 5 ;;
  esac
done
```

A CI runner has no interactive terminal, so `apply` is fire-and-forget there: it
submits the command and returns as soon as the agent accepts it. Use the
`formae status command` poll above wherever the pipeline result has to gate a
merge or a deploy - it streams status and fails the job when the apply fails.

## Simulate in the pull request, apply on merge

The everyday pipeline has two triggers. On a pull request, validate the forma and
preview the changes without touching anything. On merge to the main branch, apply
them.

<Steps>
  <Step title="Validate in the pull request">
    `formae eval` catches syntax and type errors. Running `apply` with
    `--simulate` previews the plan without making any cloud API calls, so
    reviewers see exactly what the merge would do.

    ```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
    formae eval infra/main.pkl
    formae apply --mode reconcile --simulate infra/main.pkl
    ```
  </Step>

  <Step title="Apply on merge">
    Once the change lands on the main branch, apply it. Reconcile mode brings the
    stack into the exact shape the forma describes.

    ```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
    formae apply --mode reconcile --yes infra/main.pkl
    ```
  </Step>
</Steps>

Here is the same flow as a complete pipeline for each system.

<CodeGroup>
  ```yaml GitHub Actions theme={"languages":{"custom":["/languages/pkl.json"]}}
  name: infrastructure
  on:
    pull_request:
      paths: ["infra/**"]
    push:
      branches: [main]
      paths: ["infra/**"]

  jobs:
    validate:
      if: github.event_name == 'pull_request'
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v4
        - name: Install formae
          run: /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)"
        - name: Validate
          run: formae eval infra/main.pkl
        - name: Preview changes
          run: formae apply --mode reconcile --simulate infra/main.pkl

    deploy:
      if: github.event_name == 'push'
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v4
        - name: Install formae
          run: /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)"
        - name: Apply
          run: formae apply --mode reconcile --yes infra/main.pkl
  ```

  ```yaml GitLab CI theme={"languages":{"custom":["/languages/pkl.json"]}}
  stages: [validate, deploy]

  .install: &install
    - /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)"

  validate:
    stage: validate
    rules:
      - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    script:
      - *install
      - formae eval infra/main.pkl
      - formae apply --mode reconcile --simulate infra/main.pkl

  deploy:
    stage: deploy
    rules:
      - if: $CI_COMMIT_BRANCH == "main"
    script:
      - *install
      - formae apply --mode reconcile --yes infra/main.pkl
  ```
</CodeGroup>

<Warning>
  The `deploy` jobs above are fire-and-forget: `apply` returns as soon as the
  command is accepted, so the job can go green before the deploy finishes. For a
  hard pass/fail gate on the main branch, follow the apply with the
  [`formae status command` check](#gate-the-pipeline-on-the-outcome) so the job
  waits for the terminal state and fails on `Failed` or `Canceled`.
</Warning>

## Parameterize per environment

You rarely want a separate forma per environment. Declare
[properties](/documentation/concepts/properties) for the parts that vary and
formae turns each one into a CLI flag automatically. One forma then deploys to
staging or production by passing different flags.

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
formae apply --mode reconcile --yes --env staging    infra/main.pkl
formae apply --mode reconcile --yes --env production infra/main.pkl
```

Here `--env` exists because the forma declares an `env` property. To see which
flags a forma exposes, pass it to `--help`:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
formae apply --help infra/main.pkl
```

The output lists a `Properties:` section with each flag, its default, and whether
it is required. For the full pattern, including how to constrain inputs so a bad
value fails before any cloud call, see
[Build self-service infrastructure](/documentation/guides/build-self-service-infrastructure).

## Pass values between stages

A common shape is provision, then deploy, then verify: one job creates
infrastructure, the next needs a computed value from it (a hostname, an ARN, a
generated name), and the last smoke-tests the result. The machine output of
`formae inventory` is the bridge between stages.

Computed values live under `ReadOnlyProperties`; values you declared in the forma
live under `Properties`. Query the resource you just created and pull the field
you need:

<CodeGroup>
  ```yaml GitHub Actions theme={"languages":{"custom":["/languages/pkl.json"]}}
  provision:
    runs-on: ubuntu-latest
    outputs:
      db_host: ${{ steps.capture.outputs.db_host }}
    steps:
      - uses: actions/checkout@v4
      - name: Install formae
        run: /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)"
      - name: Provision
        run: formae apply --mode reconcile --yes infra/database.pkl
      - id: capture
        name: Capture DB host
        run: |
          DB_HOST=$(formae inventory resources \
            --query='label:pg-server' \
            --output-consumer machine --output-schema json \
            | jq -r '.Resources[0].ReadOnlyProperties.fullyQualifiedDomainName')
          echo "db_host=$DB_HOST" >> "$GITHUB_OUTPUT"

  deploy:
    needs: provision
    runs-on: ubuntu-latest
    steps:
      - name: Deploy
        run: deploy_cmd --db-host=${{ needs.provision.outputs.db_host }}
  ```

  ```yaml GitLab CI theme={"languages":{"custom":["/languages/pkl.json"]}}
  provision:
    stage: provision
    script:
      - /bin/bash -c "$(curl -fsSL https://hub.platform.engineering/get/formae.sh)"
      - formae apply --mode reconcile --yes infra/database.pkl
      - |
        DB_HOST=$(formae inventory resources \
          --query='label:pg-server' \
          --output-consumer machine --output-schema json \
          | jq -r '.Resources[0].ReadOnlyProperties.fullyQualifiedDomainName')
        echo "DB_HOST=$DB_HOST" >> deploy.env
    artifacts:
      reports:
        dotenv: deploy.env

  deploy:
    stage: deploy
    needs: [provision]
    script:
      - deploy_cmd --db-host="$DB_HOST"
  ```
</CodeGroup>

The two systems forward the value differently. GitHub Actions writes it to
`$GITHUB_OUTPUT` and reads it back through `needs.provision.outputs`. GitLab CI
writes it to a `dotenv` artifact and later jobs that `needs` this one get the
variable injected automatically. The formae side of the bridge is identical: one
`formae inventory` query, one `jq` extraction.

## Read a discovered resource in a pipeline

Sometimes a stage needs a value from a resource that lives in your cloud account
but is not managed by formae: a legacy database owned by another team, shared
infrastructure, anything that predates formae. Hard-coding the value in a CI
variable works once and then rots. Reading it through
[discovery](/documentation/concepts/discovery) keeps the pipeline correct for as
long as the resource exists.

First make the resource discoverable by applying a target that points at the
account or region where it lives. No stack, no resources, just a target:

```kotlin theme={"languages":{"custom":["/languages/pkl.json"]}}
amends "@formae/forma.pkl"
import "@formae/formae.pkl"
import "@aws/aws.pkl"

forma {
    new formae.Target {
        label = "shared-rds-account"
        config = new aws.Config { region = "us-east-1" }
    }
}
```

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
formae apply --mode reconcile --yes target.pkl
```

Discovery scans the target on an interval and surfaces the resources it finds
with `managed: false`: visible to `inventory` and `extract`, untouched by
`apply`. Once the resource has been picked up, query it exactly as you would a
managed one, with a `managed:false` filter:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
DB_HOST=$(formae inventory resources \
  --query='managed:false label:legacy-orders-db' \
  --output-consumer machine --output-schema json \
  | jq -r '.Resources[0].ReadOnlyProperties.endpoint.address')
```

If you later decide to bring the resource under management, slice it out by query
and give it a stack:

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
formae extract --query='managed:false label:legacy-orders-db' orders-db.pkl
```

Edit `orders-db.pkl` to add a stack, then
`formae apply --mode reconcile orders-db.pkl`. For a larger estate, slice by
team, type, or environment, one stack per slice. See
[Bring resources under management](/documentation/guides/bring-resources-under-management)
for the full adoption path.

## Provision the pipeline itself as a forma

The pipelines above run formae. You can also let formae manage the pipeline. When
you have a plugin for your CI system, the workflow file, the variables and
secrets it depends on, and the environments it deploys into are all resources.
One `formae apply` lays them all down.

```kotlin theme={"languages":{"custom":["/languages/pkl.json"]}}
import "@gha/repo/repoworkflow.pkl" as workflow
import "@com.github.actions/Workflow.pkl" as GHAWorkflow

local deployWorkflow = new workflow.Workflow {
    label = "deploy"
    path = ".github/workflows/deploy.yml"
    name = "Deploy"
    on = new GHAWorkflow.On { workflow_dispatch {} }
    permissions = new GHAWorkflow.Permissions {
        `id-token` = "write"
        contents = "read"
    }
    jobs {
        ["deploy"] {
            `runs-on` = "ubuntu-latest"
            steps {
                new { uses = "actions/checkout@v4" }
                new { name = "Deploy"; run = "./deploy.sh" }
            }
        }
    }
}
```

Applying this forma writes `.github/workflows/deploy.yml` into the repository as a
managed resource. When the workflow definition is code, editing it is an ordinary
forma change that flows through the same review-and-apply pipeline as everything
else.

## Fan out across many repositories

To run the same pipeline shape across many repositories with per-repository
overrides, wrap the pieces in a Pkl class and instantiate it once per service.
Each instance carries its own [target](/documentation/concepts/target), so a
single forma provisions the whole fleet.

```kotlin theme={"languages":{"custom":["/languages/pkl.json"]}}
// service_pipeline.pkl
module service_pipeline

import "@formae/formae.pkl"
import "@gha/gha.pkl"
import "@gha/repo/repovariable.pkl" as repovar
import "@gha/repo/repoworkflow.pkl" as workflow
import "@gha/env/environment.pkl"
import "@gha/env/envsecret.pkl"
import "@com.github.actions/Workflow.pkl" as GHAWorkflow

class ServicePipeline {
    orgName: String
    repoName: String
    awsRegion: String = "us-east-1"
    prodRoleArn: String
    prodBranchPattern: String = "main"

    hidden target: formae.Target = new formae.Target {
        label = "gha-\(repoName)"
        namespace = "GHA"
        config = new gha.Config {
            owner = orgName
            repo = repoName
        }
    }

    hidden projectVar: repovar.Variable = new {
        label = "\(repoName)-project"
        target = target.res
        name = "PROJECT_NAME"
        value = repoName
    }

    hidden prodEnv: environment.Environment = new {
        label = "\(repoName)-production"
        target = target.res
        name = "production"
        preventSelfReview = true
    }

    hidden prodRoleSecret: envsecret.Secret = new {
        label = "\(repoName)-prod-role"
        target = target.res
        environment = prodEnv.res.name
        name = "AWS_ROLE_ARN"
        value = prodRoleArn
    }

    hidden deployWorkflow: workflow.Workflow = new {
        label = "\(repoName)-deploy"
        target = target.res
        path = ".github/workflows/deploy.yml"
        name = "Deploy"
        on = new GHAWorkflow.On {
            push { branches { prodBranchPattern } }
        }
        permissions = new GHAWorkflow.Permissions {
            `id-token` = "write"
            contents = "read"
        }
        jobs {
            ["deploy"] {
                `runs-on` = "ubuntu-latest"
                environment { name = "production" }
                steps {
                    new { uses = "actions/checkout@v4" }
                    new {
                        name = "Configure AWS"
                        uses = "aws-actions/configure-aws-credentials@v4"
                        `with` {
                            ["role-to-assume"] = "${{ secrets.AWS_ROLE_ARN }}"
                            ["aws-region"] = "${{ vars.AWS_DEFAULT_REGION }}"
                        }
                    }
                    new { name = "Deploy"; run = "./deploy.sh" }
                }
            }
        }
    }

    resources: Listing<formae.Resource|formae.Target> = new {
        target
        projectVar
        prodEnv
        prodRoleSecret
        deployWorkflow
    }
}
```

The fleet manifest lists one entry per service. Class defaults carry the common
case, and you override only the fields that differ:

```kotlin theme={"languages":{"custom":["/languages/pkl.json"]}}
// main.pkl
amends "@formae/forma.pkl"

import "@formae/formae.pkl"
import "./service_pipeline.pkl" as sp

local stack = new formae.Stack {
    label = "service-fleet"
    description = "Deploy pipelines for all service repos"
}

local orgName = read("env:GHA_OWNER")

local services: Listing<sp.ServicePipeline> = new {
    new {
        orgName = orgName
        repoName = "orders-api"
        prodRoleArn = "arn:aws:iam::111111111111:role/orders-deploy"
    }
    new {
        orgName = orgName
        repoName = "payments-api"
        awsRegion = "us-west-2"
        prodRoleArn = "arn:aws:iam::111111111111:role/payments-deploy"
    }
    new {
        orgName = orgName
        repoName = "catalog-api"
        prodRoleArn = "arn:aws:iam::111111111111:role/catalog-deploy"
        prodBranchPattern = "release/*"
    }
}

forma {
    stack
    for (svc in services) {
        ...svc.resources
    }
}
```

```bash theme={"languages":{"custom":["/languages/pkl.json"]}}
export GHA_OWNER=my-org
formae apply --mode reconcile --yes main.pkl
```

Adding a repository means appending one listing entry and re-applying. Changing a
step in the class updates every repository at once. Because this is reconcile
mode, anything in the `service-fleet` stack that is no longer declared gets
removed, so the forma stays the single source of truth for the whole fleet. The
class pattern is covered in depth in
[Reuse with modules](/documentation/guides/reuse-with-modules).

## Tips and gotchas

<Note>
  One agent serves many clients. Multiple pipelines and humans can connect to the
  same agent at the same time. Do not start an agent per job.
</Note>

<Warning>
  Concurrent applies are safe when each pipeline works on its own stack and target.
  Conflicts arise when two applies touch the same stack and target at once. For
  ephemeral environments, put a unique ID in the stack label so each pipeline gets
  its own stack. For shared stacks, serialize the deploys.
</Warning>

## See also

* [Apply modes](/documentation/concepts/apply-modes): reconcile versus patch, and what each one is allowed to do.
* [Ways to work with formae](/documentation/concepts/ways-to-work): where a Git-driven pipeline sits among the operating models.
* [Build self-service infrastructure](/documentation/guides/build-self-service-infrastructure): turn properties into a parameterized interface developers drive with flags.
* [Discovery](/documentation/concepts/discovery): how formae finds and tracks resources you have not brought under management yet.
* [CLI reference](/documentation/reference/cli): every command and flag in full.
