Lightweight Terraform and OpenTofu orchestration on GitHub Actions.
Website · Documentation · stackorder/stackorder
GitHub Actions and reusable workflows for Stackorder, the lightweight Terraform and OpenTofu orchestrator on GitHub Actions.
Version 1 of these actions and workflows requires stackorder/stackorder v0.1.0 or later, for both the server and the stackorder CLI they install.
All runner-side logic lives in the stackorder CLI. This repository installs it and wraps it: one JavaScript action that downloads and verifies the binary, four composite actions that only pass arguments to it, and two reusable workflows that your repository calls from two short workflow files. Nothing here uses Docker, so self-hosted runners without a Docker socket work unchanged.
| Path | Kind | What it does |
|---|---|---|
setup |
JavaScript action (node24) |
Downloads the stackorder release for the runner, verifies its SHA-256, caches it and adds it to PATH |
resolve |
Composite action | stackorder resolve: scans the repository, posts the graph, exposes the plan matrix |
plan |
Composite action | stackorder plan for one stack, then uploads the plan file as an artifact |
apply |
Composite action | Downloads a stack's plan artifact from the plan run, then stackorder apply |
drift |
Composite action | stackorder drift for one stack; drift found is an output, not a failure |
.github/workflows/plan.yml |
Reusable workflow | Pull request plans: resolve, then one plan job per affected stack |
.github/workflows/run.yml |
Reusable workflow | Server-dispatched plan, apply or drift for one wave of stacks |
A repository needs a stackorder.yaml and two workflow files. The design document gives the two files as:
.github/workflows/stackorder-plan.yml
name: stackorder plan
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: stackorder-plan-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
plan:
uses: stackorder/actions/.github/workflows/plan.yml@v1
with:
aws-role-arn: arn:aws:iam::123456789012:role/stackorder-plan
tool: tofu.github/workflows/stackorder-run.yml
name: stackorder run
on:
workflow_dispatch:
inputs:
run_id: { type: string, required: true }
mode: { type: string, required: true } # resolve | apply | drift
wave: { type: string, required: false }
stacks: { type: string, required: false } # JSON array; each entry carries its environment
jobs:
run:
uses: stackorder/actions/.github/workflows/run.yml@v1
with:
run-id: ${{ inputs.run_id }}
mode: ${{ inputs.mode }}
wave: ${{ inputs.wave }}
stacks: ${{ inputs.stacks }}
aws-role-arn-map: '{"stacks/prod/": "arn:aws:iam::123456789012:role/stackorder-apply-prod", "stacks/staging/": "arn:aws:iam::123456789012:role/stackorder-apply-staging"}'
# each apply job runs under the GitHub environment from stackorder.yaml,
# so the environment's protection rules gate itThose two files show the shape, but they do not run as written. Four additions are required:
server-url. Both reusable workflows require the base URL of your Stackorder server.permissions. A called workflow can only lower theGITHUB_TOKENpermissions its caller grants, never raise them, andid-token: writeis never granted by default. The calling job must grant everything the called jobs use (see Permissions).sha. The server dispatchesstackorder-run.ymlwith ashainput, the commit each job checks out, andmodeisplan,applyordrift.run-name.stackorder-run.ymlmust setrun-name: stackorder ${{ inputs.mode }} ${{ inputs.run_id }} wave ${{ inputs.wave }}; the server recognises the workflow runs it dispatched by that title.
Neither file passes secrets: inherit: GitHub passes inherited secrets only to a reusable workflow in the caller's own organization or enterprise, so from a repository outside the stackorder organization it passes nothing. To give Terraform or OpenTofu credentials besides AWS, pass the env secret by name (see Provider credentials and environment variables).
Server-dispatched plans and drift checks run under the environment default and assume aws-plan-role-arn (falling back to aws-role-arn), so the complete stackorder-run.yml passes the plan role too.
The complete files:
name: stackorder plan
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: stackorder-plan-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
plan:
uses: stackorder/actions/.github/workflows/plan.yml@v1
permissions:
id-token: write
contents: read
checks: write
pull-requests: read
actions: read
with:
server-url: https://stackorder.example.com
aws-role-arn: arn:aws:iam::123456789012:role/stackorder-plan
tool: tofu
# stackorder-version: 0.1.0 # optional: pin the CLI release; the default is latestname: stackorder run
run-name: stackorder ${{ inputs.mode }} ${{ inputs.run_id }} wave ${{ inputs.wave }}
on:
workflow_dispatch:
inputs:
run_id: { type: string, required: true }
mode: { type: string, required: true } # plan | apply | drift
wave: { type: string, required: false }
sha: { type: string, required: false }
stacks: { type: string, required: true } # JSON array; each entry carries its environment
jobs:
run:
uses: stackorder/actions/.github/workflows/run.yml@v1
permissions:
id-token: write
contents: read
actions: read
checks: write
with:
server-url: https://stackorder.example.com
run-id: ${{ inputs.run_id }}
mode: ${{ inputs.mode }}
wave: ${{ inputs.wave }}
sha: ${{ inputs.sha }}
stacks: ${{ inputs.stacks }}
aws-plan-role-arn: arn:aws:iam::123456789012:role/stackorder-plan
aws-role-arn-map: '{"stacks/prod/": "arn:aws:iam::123456789012:role/stackorder-apply-prod", "stacks/staging/": "arn:aws:iam::123456789012:role/stackorder-apply-staging"}'
# stackorder-version: 0.1.0 # optional: pin the CLI release; the default is latestBranch protection on the default branch should require the stackorder/plan and stackorder/apply checks.
Every job declares the minimum it needs, and the workflow-level default is permissions: {}.
| Workflow | Job | id-token |
contents |
checks |
pull-requests |
actions |
Why |
|---|---|---|---|---|---|---|---|
plan.yml |
resolve |
write | read | write | read | OIDC to the server; checkout; fallback check when the server is unreachable; pull request base and head | |
plan.yml |
plan |
write | read | write | read | OIDC to AWS and the server; checkout; fallback check; reading workflow run and job metadata | |
plan.yml |
fork-notice |
Writes only the job summary | |||||
run.yml |
run |
write | read | write | read | OIDC to AWS and the server; checkout; fallback check; download of the plan artifact from the plan run |
The caller grants the union: for stackorder-plan.yml that is id-token: write, contents: read, checks: write, pull-requests: read and actions: read; for stackorder-run.yml it is id-token: write, contents: read, actions: read and checks: write.
Called from stackorder-plan.yml on pull_request. Three jobs:
resolvechecks out the pull request head, not therefs/pull/N/mergecommit, with full history, installsstackorder, runs theresolveaction and exposesmatrix,count,run-id,unconfirmedand the installedstackorder-version, which every plan job installs in turn, solatestis resolved once per run. It runs only when the head repository is not a fork.planruns once per affected stack from the resolve matrix, withfail-fast: falseandmax-parallel, and only whencountis above zero. Each job checks out the entry'ssha, the commit the resolve step scanned (the event's commit when the entry has none), installs Terraform (hashicorp/setup-terraform@v3) or OpenTofu (opentofu/setup-opentofu@v1) with the wrapper disabled, installsstackorder, exportsenv, selects the AWS role, assumes it withaws-actions/configure-aws-credentials@v4, restores the provider plugin cache keyed on the stack's.terraform.lock.hcl, and runs theplanaction. Plan jobs never declare anenvironment, so planning is never held behind an environment's reviewers.fork-noticeruns instead when the head repository is a fork and explains in the job summary why nothing was planned (see Fork pull requests).
| Input | Type | Default | Description |
|---|---|---|---|
server-url |
string | required | Base URL of the Stackorder server |
aws-role-arn |
string | '' |
IAM role for every stack that matches no key in aws-role-arn-map |
aws-role-arn-map |
string | '' |
JSON object from prefix/, path:instance or :instance to IAM role ARN (see AWS role selection) |
aws-role-session-name |
string | '' |
Role session name, or a JSON object whose plan key is used; empty keeps the configure-aws-credentials default (see Role session name) |
aws-region |
string | us-east-1 |
AWS region for the credentials |
tool |
string | terraform |
terraform or tofu, used when a stack's matrix entry names no tool |
tool-version |
string | latest |
Tool version, used when a stack's matrix entry pins none |
stackorder-version |
string | latest |
stackorder release to install: 1.2.3, v1.2.3 or latest |
runner |
string | ubuntu-latest |
Runner label, or a JSON array or object passed to runs-on |
max-parallel |
number | 6 |
Maximum number of stacks planned at the same time |
working-directory |
string | . |
Directory stackorder runs in, relative to the repository root |
base-ref |
string | '' |
Git ref to diff against; empty uses the pull request base |
stacks |
string | '' |
Comma separated stack keys to restrict the plan to; empty plans every affected stack |
env |
string | '' |
Environment variables for each plan job, as KEY=VALUE lines or KEY<<DELIMITER multi-line values (see Provider credentials and environment variables) |
| Secret | Required | Description |
|---|---|---|
env |
no | Environment variables with secret values for each plan job, in the syntax of the env input; every value is masked in the log |
Called from stackorder-run.yml, which the server dispatches once per wave and GitHub environment. One job, run, fans out over the stacks input with fail-fast: false and max-parallel:
environment: ${{ matrix.environment }}puts each stack under the GitHub environment the server assigned it (defaultwhen no prefix matches, never empty), so the environment's required reviewers, deployment protection rules and the AWS trust policy'senvironmentcondition gate the job.concurrency: stackorder-stack-<stack key>withoutcancel-in-progresskeeps two jobs from running on the same stack at once. Thestack-prefix keeps the group apart from the caller'sstackorder-plan-<pull request>group, since concurrency groups are shared by every workflow and job in the repository. GitHub keeps at most one pending job per concurrency group, so a third job queued for the same stack replaces the pending one.- The job checks out
sha, installs the stack's tool (the entry'stoolandtool_versionwhen set, otherwise thetoolandtool-versioninputs), installsstackorder, exportsenv, selects and assumes the AWS role, restores the plugin cache, then runs exactly one of theplan,applyordriftactions according tomode. Any othermodefails the job before checkout.
| Input | Type | Default | Description |
|---|---|---|---|
run-id |
string | required | Stackorder run id from the dispatch |
mode |
string | required | plan, apply or drift |
wave |
string | '' |
Wave index of this dispatch, shown in job names |
sha |
string | '' |
Commit to check out; empty falls back to each entry's sha, then to the dispatched ref |
stacks |
string | required | JSON array of v1.MatrixEntry objects, each carrying its GitHub environment; for applies also plan_run_id and artifact |
server-url |
string | required | Base URL of the Stackorder server |
aws-role-arn-map |
string | '' |
JSON object from prefix/, path:instance or :instance to the IAM role ARN for applies (see AWS role selection) |
aws-role-arn |
string | '' |
IAM role for every stack that matches no key in aws-role-arn-map |
aws-plan-role-arn |
string | '' |
IAM role for plan and drift dispatches, which run under the default environment and never use aws-role-arn-map; empty falls back to aws-role-arn |
aws-role-session-name |
string | '' |
Role session name, or a JSON object with plan, apply and drift keys; empty keeps the configure-aws-credentials default (see Role session name) |
aws-region |
string | us-east-1 |
AWS region for the credentials |
tool |
string | terraform |
terraform or tofu, used when an entry names no tool |
tool-version |
string | latest |
Tool version, used when an entry pins none |
stackorder-version |
string | latest |
stackorder release to install |
runner |
string | ubuntu-latest |
Runner label, or a JSON array or object passed to runs-on |
max-parallel |
number | 6 |
Maximum number of stacks run at the same time |
working-directory |
string | . |
Directory stackorder runs in, relative to the repository root |
env |
string | '' |
Environment variables for each job, as KEY=VALUE lines or KEY<<DELIMITER multi-line values (see Provider credentials and environment variables) |
| Secret | Required | Description |
|---|---|---|
env |
no | Environment variables with secret values for each job, in the syntax of the env input; every value is masked in the log. An environment secret named ENV on the job's GitHub environment takes its place, but only when the caller passes env, even from an unset secret; without it the job gets an empty value. This is the runner's observed behaviour, which GitHub does not document |
A stacks entry looks like this (the shape of v1.MatrixEntry):
{
"stack": "stacks/prod/vpc",
"key": "stacks/prod/vpc",
"instance": "",
"workspace": "",
"environment": "production",
"wave": 0,
"tool": "tofu",
"tool_version": "1.9.0",
"plan_output": "full",
"sha": "4f1c0de2b9a8e7d6c5b4a3928170f6e5d4c3b2a1",
"plan_run_id": 16123456789,
"artifact": "stackorder-plan-stacks-prod-vpc-69df0ef0-4f1c0de2b9a8e7d6c5b4a3928170f6e5d4c3b2a1"
}-
AWS role selection. A small step picks the role for the stack from its key (
matrix.key), path (matrix.stack) and instance (matrix.instance, ormatrix.workspacefor entries from an older CLI).aws-role-arn-mapkeys take three forms, and the first that matches wins:path:instance, an exact stack key such asinfra/kyc:production;:instance, that instance in any directory, such as:production;- a path prefix such as
stacks/prod/, matched on whole path segments (stacks/prod/andstacks/prodboth matchstacks/prod/vpc,stacks/prodoes not); the longest matching prefix wins.
With no match the step uses
aws-role-arn. When neither yields a role the job logs a notice and skipsconfigure-aws-credentials, which suits self-hosted runners with an instance role. Readingaws-role-arn-mapneedsjqon the runner; GitHub-hosted runners have it, self-hosted runners may need it installed. Formode: planandmode: driftinrun.ymlthe map is ignored: those dispatches run under thedefaultenvironment and assumeaws-plan-role-arn, falling back toaws-role-arn. Pull request plans inplan.ymluse the map passed toplan.yml, which must hold only read-only plan roles (see AWS roles for stack instances). -
Role session name.
aws-role-session-nameis passed toconfigure-aws-credentialsasrole-session-name, so CloudTrail shows which workflow assumed the role. It is either a name or a JSON object withplan,applyanddriftkeys, such as{"plan": "stackorder-plan", "apply": "stackorder-apply"};driftfalls back toplan, andplan.ymlalways usesplan. Characters outside[\w+=,.@-]become-and the name is cut to 64 characters; a name of one character fails the job, since AWS needs 2 to 64. An empty name, or a JSON object with no value for the mode, keeps the action's default,GitHubActions. -
Environment variables. The
Export envstep appends theenvinput, then theenvsecret, to$GITHUB_ENV, so every later step of the job sees them: the AWS credentials step, thestackorderaction, Terraform or OpenTofu and the hooks. Theresolvejob never gets them. See Provider credentials and environment variables. -
Tool selection. The job sets
STACKORDER_TOOLto the tool it installed, so the CLI always calls the binary that is onPATH. -
Plugin cache.
TF_PLUGIN_CACHE_DIRpoints at$RUNNER_TEMP/terraform-plugin-cache, cached withactions/cache@v4under a key built from the runner OS and architecture, the tool and the hash of the stack's.terraform.lock.hcl, found underworking-directory. -
Hooks.
.stackorder/hooks/pre-plan.sh,post-plan.sh,pre-apply.shandpost-apply.share run by the CLI itself, withSTACKORDER_STACK,STACKORDER_STACK_PATH,STACKORDER_INSTANCE,STACKORDER_RUN_ID,STACKORDER_PLAN_JSONandSTACKORDER_PLAN_FILEset, plus the stack's configuredenvfor the mode, so they behave the same in CI and on a laptop and need no workflow step. -
Internal action references. The reusable workflows use
stackorder/actions/setup@v1and the other actions at@v1. A reusable workflow cannot refer to actions in its own repository by relative path when another repository calls it, so it names them in full; this repository's CI runs the same actions from their local paths.
A stack directory with several instances, such as infra/kyc:production and infra/kyc:staging, can get its AWS access in two ways.
One OIDC role per instance. Map each instance to its own role. The GitHub environment of each apply job gates it, and each apply role's trust policy must require that environment (repo:acme/infra:environment:production). The map picks a role from the stack key, path and instance name, which a pull request can change, so the environment in the trust policy is what ties an apply role to its gate:
aws-role-arn-map: '{":production": "arn:aws:iam::111111111111:role/stackorder-apply", ":staging": "arn:aws:iam::222222222222:role/stackorder-apply", "infra/kyc:production": "arn:aws:iam::111111111111:role/stackorder-apply-kyc"}'aws-role-arn-map applies only to the workflow it is passed to. Never pass the apply map to plan.yml: pull request plans run the pull request's code with no GitHub environment, so give plan.yml a read-only plan role in aws-role-arn, or its own aws-role-arn-map of plan-only roles that trust the repository's pull_request tokens. Server-dispatched plans and drift checks in run.yml assume the single aws-plan-role-arn, so that role must be able to read every instance's state.
One bootstrap role, provider assume_role per instance. The workflows assume one OIDC role per mode: aws-role-arn in plan.yml for pull request plans, and in run.yml aws-plan-role-arn for plan and drift dispatches and aws-role-arn for applies. The Terraform or OpenTofu provider then assumes the instance's own role in its assume_role block, read from a variable that the repository's env configuration in stackorder.yaml sets per instance, with a plan and an apply value (see env and One bootstrap role and a provider role per account on the Stack instances page of the Stackorder documentation). Declare that variable ephemeral (Terraform 1.10 or later, OpenTofu 1.11 or later): the value of a non-ephemeral variable is frozen in the saved plan, so an apply would otherwise assume the plan-time role. The workflow side is only the two roles:
# stackorder-plan.yml
with:
aws-role-arn: arn:aws:iam::123456789012:role/stackorder-bootstrap-plan
aws-role-session-name: stackorder-plan# stackorder-run.yml
with:
aws-plan-role-arn: arn:aws:iam::123456789012:role/stackorder-bootstrap-plan
aws-role-arn: arn:aws:iam::123456789012:role/stackorder-bootstrap-apply
aws-role-session-name: '{"plan": "stackorder-plan", "apply": "stackorder-apply"}'Each bootstrap role needs sts:AssumeRole on the instance roles, and each instance role must trust it. A session reached by role chaining lasts at most one hour, whatever the role's maximum session duration, so a longer plan or apply fails when its credentials expire.
AWS credentials come from the OIDC role. Every other credential a provider needs, and any other environment variable, reaches the jobs through two values that both workflows take:
- the
envinput, for values that are not secret: they are in the workflow file and may show in the log; - the
envsecret, for secret values: the job registers every line of every value with::add-mask::before it exports anything, so the runner replaces them with***in the log. Keep other values out of it: a short value such as1ortruewould be replaced wherever it appears in the job's log.
Both use the syntax of $GITHUB_ENV: one KEY=VALUE per line, or a multi-line value such as a PEM key between KEY<<DELIMITER and a line holding only DELIMITER.
CLOUDFLARE_API_TOKEN=0123456789abcdef
TLS_CLIENT_KEY<<EOF
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
EOF
The Export env step runs in the plan job of plan.yml and the run job of run.yml, after stackorder is installed and before the AWS credentials step, and appends the input and then the secret to $GITHUB_ENV: for a name in both, the secret's value wins. The rules:
- Names. A name matches
[A-Za-z_][A-Za-z0-9_]*. Names starting withGITHUB_,RUNNER_,ACTIONS_,STACKORDER_orLD_, andPATH,HOME,NODE_OPTIONS,BASH_ENV,BASHOPTS,SHELLOPTSandPS4, are refused in any letter case, since they would redirect the runner, the later steps or thestackorderCLI. These include the names the CLI refuses in theenvkey ofstackorder.yaml. The list guards against mistakes; it is not a security boundary, since the stack's own code runs in the same job. - Errors. A reserved name, or a name that does not match, fails the job with an error naming the reserved name or the line's number, and reading goes on to report the others. A line that is neither
KEY=VALUEnorKEY<<DELIMITER,<<with no delimiter, or a multi-line value with no closing line fails the job with the line's number and stops reading that value. An error never shows a line's text, and nothing is exported when there is any error. - Blank lines outside a multi-line value are skipped, and a trailing carriage return is dropped from every line.
- Later steps. When the job assumes a role,
configure-aws-credentialssetsAWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN,AWS_REGIONandAWS_DEFAULT_REGIONafter this step, so they replace any of the same name fromenv. When no role applies, the step is skipped and these names keep theirenvvalues. OtherAWS_variables, such asAWS_PROFILEorAWS_ENDPOINT_URL, stay set and change how the AWS SDKs and the S3 backend find credentials and endpoints.TF_PLUGIN_CACHE_DIRis set after this step too.
secrets: inherit gives a reusable workflow the caller's secrets only when both are in the same organization or enterprise. For a repository outside the stackorder organization it passes nothing, so pass the secret by name:
secrets:
env: ${{ secrets.STACKORDER_ENV }}Where secrets: inherit does apply, such as a copy of these workflows in your own organization, the declared env secret reads the caller's secret named ENV: secret names are case insensitive.
The run job of run.yml declares environment: ${{ matrix.environment }}, and GitHub gives a called job that declares an environment that environment's secret in place of the secret of the same name from the caller. An environment secret named ENV therefore replaces the whole env secret, without merging, in the jobs under that environment: applies under production read the ENV secret of production, and server-dispatched plans and drift checks, which run under default, read the one of default. Plan jobs of plan.yml declare no environment and get only what the caller passes.
A pull request plan runs the pull request's code, and so does a plan the server dispatches for a pull request: that code can read every variable of the job and send it anywhere. Treat env like the AWS roles:
- give pull request plans, and the
defaultenvironment, only read-only tokens; - keep tokens that can change infrastructure in
ENVenvironment secrets of the environments whose protection rules gate applies, never in a repository or organization secret, which a pull request can pass to its own plan by editing the calling workflow.
The Cloudflare provider reads CLOUDFLARE_API_TOKEN. Create a read-only API token and store it as the repository secret STACKORDER_ENV with the value CLOUDFLARE_API_TOKEN=<read-only token>, then create a token that can edit the same resources and store CLOUDFLARE_API_TOKEN=<edit token> as the environment secret ENV of production. Both wrappers pass the repository secret, and the account id, which is not secret, as an input:
# stackorder-plan.yml
jobs:
plan:
uses: stackorder/actions/.github/workflows/plan.yml@v1
permissions:
id-token: write
contents: read
checks: write
pull-requests: read
actions: read
with:
server-url: https://stackorder.example.com
aws-role-arn: arn:aws:iam::123456789012:role/stackorder-plan
env: |
CLOUDFLARE_ACCOUNT_ID=0123456789abcdef0123456789abcdef
secrets:
env: ${{ secrets.STACKORDER_ENV }}# stackorder-run.yml
jobs:
run:
uses: stackorder/actions/.github/workflows/run.yml@v1
permissions:
id-token: write
contents: read
actions: read
checks: write
with:
server-url: https://stackorder.example.com
run-id: ${{ inputs.run_id }}
mode: ${{ inputs.mode }}
wave: ${{ inputs.wave }}
sha: ${{ inputs.sha }}
stacks: ${{ inputs.stacks }}
aws-plan-role-arn: arn:aws:iam::123456789012:role/stackorder-plan
aws-role-arn-map: '{"stacks/prod/": "arn:aws:iam::123456789012:role/stackorder-apply-prod"}'
env: |
CLOUDFLARE_ACCOUNT_ID=0123456789abcdef0123456789abcdef
secrets:
env: ${{ secrets.STACKORDER_ENV }}Pull request plans and dispatched plans get the read-only token; applies under production get the edit token from the environment secret, and only once the environment's protection rules have passed. A provider environment variable is never written to the plan file.
A provider argument can take the token from a variable instead. Terraform and OpenTofu read a variable x from TF_VAR_x, so the secret holds TF_VAR_cloudflare_api_token=<token>. Declare the variable ephemeral (Terraform 1.10 or later, OpenTofu 1.11 or later):
variable "cloudflare_api_token" {
type = string
sensitive = true
ephemeral = true
}
provider "cloudflare" {
api_token = var.cloudflare_api_token
}A saved plan stores the value of every variable that is not ephemeral. The plan action uploads the plan file as a workflow artifact, which anyone who can read the repository can download, and stackorder apply applies the saved plan with the value it holds, so without ephemeral the plan-time token would be both published and used for the apply. An ephemeral variable is left out of the plan and read again at apply time, from the apply job's env.
The composite actions contain no logic beyond passing inputs to the CLI, so any of them can be replaced by a direct run: stackorder … step. They expect stackorder on PATH (from setup); inputs are passed through environment variables, never interpolated into scripts.
- uses: stackorder/actions/setup@v1
with:
version: 0.1.0Maps the runner to linux, darwin or windows and amd64 or arm64, downloads stackorder_<version>_<os>_<arch>.tar.gz (.zip on Windows) from https://github.com/stackorder/stackorder/releases/download/v<version>/, checks it against the sha256 line for that file in stackorder_<version>_checksums.txt, extracts it, caches it with @actions/tool-cache under the tool name stackorder, and adds it to PATH. A version already in the runner's tool cache is used without downloading. Any failure (unsupported runner, unknown version, missing asset, checksum mismatch, archive without the binary) fails the step with a message naming the version, platform and URL.
| Input | Default | Description |
|---|---|---|
version |
latest |
Release to install, as 1.2.3 or v1.2.3; latest resolves the newest release through GET https://api.github.com/repos/stackorder/stackorder/releases/latest |
token |
${{ github.token }} on github.com, empty elsewhere |
github.com token sent to the GitHub API when resolving latest. A GitHub Enterprise Server token is never sent to github.com; there, latest is resolved anonymously unless you pass a github.com token, or pin version |
checksum |
true |
Verify the archive against the release checksums file: true or false, and any other value fails the step |
| Output | Description |
|---|---|
version |
Installed version, without the leading v |
path |
Absolute path to the installed stackorder binary |
Runs stackorder resolve --server <server-url> [--base <base-ref>] [--stacks <stacks>].
| Input | Default | Description |
|---|---|---|
base-ref |
'' |
Git ref to diff against; empty lets the CLI use the pull request base |
stacks |
'' |
Comma separated stack keys to restrict the run to |
server-url |
required | Base URL of the Stackorder server |
working-directory |
. |
Directory to run stackorder in |
github-token |
${{ github.token }} |
Token used to post a neutral check when the server is unreachable |
| Output | Description |
|---|---|
matrix |
JSON object {"include": [...]} of v1.MatrixEntry, for strategy.matrix |
waves |
JSON array of stack keys per wave |
affected |
JSON array of affected stacks and why each is affected |
count |
Number of affected stacks |
run-id |
Stackorder run id |
unconfirmed |
true when the server was unreachable and the CLI resolved locally |
Runs stackorder plan --stack <stack> --run-id <run-id> --server <server-url> with STACKORDER_PLAN_DIR set to $GITHUB_WORKSPACE/.stackorder/plans, then uploads the plan file with actions/upload-artifact@v4 under the name the CLI reports (stackorder-plan-<slug>-<sha>, where the slug is the stack key with / and : replaced by - followed by - and the first 8 hex characters of the key's SHA-256), failing if the file is missing.
| Input | Default | Description |
|---|---|---|
stack |
required | Stack key, as path or path:instance |
run-id |
required | Stackorder run id |
server-url |
required | Base URL of the Stackorder server |
working-directory |
. |
Directory to run stackorder in |
upload-artifact |
true |
Upload the plan file; only true uploads |
retention-days |
5 |
Days to keep the plan artifact |
github-token |
${{ github.token }} |
Token used to post a neutral check when the server is unreachable |
| Output | Description |
|---|---|
has-changes |
true when the plan changes resources or outputs |
artifact |
Name of the plan artifact |
plan-file |
Path of the binary plan file |
summary |
JSON plan summary |
unconfirmed |
true when the result could not be confirmed by the server |
Downloads the artifact artifact from workflow run plan-run-id with actions/download-artifact@v4 into $GITHUB_WORKSPACE/.stackorder/plans, then runs stackorder apply --stack <stack> --run-id <run-id> --server <server-url> --plan-file $GITHUB_WORKSPACE/.stackorder/plans/<artifact>.tfplan. .stackorder/plans is the CLI's default STACKORDER_PLAN_DIR, anchored at the workspace so working-directory does not move it. The download step continues on error: when the artifact has expired the CLI finds no plan file, re-plans, and refuses to apply unless the new plan's resource addresses match the recorded plan.
| Input | Default | Description |
|---|---|---|
stack |
required | Stack key, as path or path:instance |
run-id |
required | Stackorder run id |
server-url |
required | Base URL of the Stackorder server |
plan-run-id |
required | Actions workflow run id that uploaded the plan artifact |
artifact |
required | Plan artifact name; the file inside is <artifact>.tfplan |
working-directory |
. |
Directory to run stackorder in |
token |
${{ github.token }} |
Token for the artifact download; needs actions: read |
| Output | Description |
|---|---|
summary |
JSON summary of the applied changes |
Runs stackorder drift --stack <stack> --run-id <run-id> --server <server-url>. Exit code 2 means drift was found: the step records it and succeeds. Any other non-zero exit code fails the step.
| Input | Default | Description |
|---|---|---|
stack |
required | Stack key, as path or path:instance |
run-id |
required | Stackorder run id |
server-url |
required | Base URL of the Stackorder server |
working-directory |
. |
Directory to run stackorder in |
github-token |
${{ github.token }} |
Token used to post a neutral check when the server is unreachable |
| Output | Description |
|---|---|
drifted |
true when real infrastructure differs from the configuration |
summary |
JSON summary of the drift |
exit-code |
Exit code of stackorder drift: 0 no drift, 2 drift found |
A pull_request run from a fork gets a read-only GITHUB_TOKEN and no id-token: write, so it can reach neither the AWS role nor the Stackorder server. plan.yml therefore skips resolve and plan for forks and runs only fork-notice, which writes an explanation to the job summary. To get plans, a maintainer can push the branch to the repository itself. Switching the caller to pull_request_target does not produce fork plans with these workflows: plan.yml skips resolve and plan whenever the head repository is a fork, whatever the event, and the server accepts plan results only from pull_request tokens.
| Reference | Moves | Use it for |
|---|---|---|
@v1 |
Yes, to every v1.x.y release |
Receiving fixes and features without breaking changes |
@v1.2.3 |
No | A fixed release of the workflow or action file you reference |
@<commit sha> |
No | Immutable pinning, for example with Dependabot or Renovate updates |
The reusable workflows call this repository's actions at @v1 and install the stackorder CLI given by stackorder-version (latest by default). Pinning plan.yml@v1.2.3 pins the workflow file, not the actions it calls; set stackorder-version to pin the CLI. If AWS trust policies or STACKORDER_REQUIRED_WORKFLOW_REF pin job_workflow_ref to stackorder/actions/.github/workflows/*.yml@refs/tags/v1*, call the workflows by a v1 tag, not by a branch or commit.
Releases are cut by pushing a vX.Y.Z tag on main. The release workflow then:
- runs the tests and checks that
setup/dist/index.jsmatches a fresh build of the source; - force-moves the major tag (
v1forv1.4.2) to the tagged commit and pushes it, but only when the tag is the newest stable release of that major, so a patch on an older line (v1.3.5afterv1.4.2) leavesv1where it is; - creates a GitHub release for the tag with generated notes.
Tags with a pre-release suffix (v1.5.0-rc.1) get a pre-release and leave the major tag where it is. A new major version (v2.0.0) starts a new major tag and leaves v1 on the last v1 release.
Node 24 and npm. setup is TypeScript in setup/src, bundled by esbuild into setup/dist/index.js, which is committed because the runner executes it directly.
npm ci
npm run lint # ESLint with typescript-eslint
npm run typecheck # tsc --noEmit
npm test # Vitest
npm run build # bundle setup/dist/index.js
npm run check-dist # build, then fail if setup/dist differs from the commitRebuild and commit setup/dist with every change to setup/src or the dependencies. CI runs the checks above, actionlint over the workflows (including copies of the reusable workflows pointed at the local actions, so their inputs are checked against the metadata in this repository), a smoke test that ./setup fails clearly for a release that does not exist, a run of the composite actions against a stub CLI in test/stackorder-stub.sh, and a check that drift passes on exit codes 0 and 2 and fails on 1 and 3.