Running StructKit in CI: Actions, Docker, and Non-Interactive Mode
A scaffolding tool that only runs on a developer's laptop covers the first day of a project's life. Everything interesting happens afterward — the CI workflow that needs updating across twenty services, the security policy that has to reach everything, the provider pins that go stale.
Getting there means running generation unattended, which introduces problems that never come up interactively: prompts with nobody to answer them, file ownership inside containers, and the question of what to do with the changes once they exist.
The bundled workflow
StructKit ships a reusable GitHub Actions workflow. The minimal call:
name: run-struct
on:
workflow_dispatch:
jobs:
generate:
uses: httpdss/structkit/.github/workflows/struct-generate.yaml@main
secrets:
token: ${{ secrets.STRUCT_RUN_TOKEN }}That checks out the repository, installs Python and StructKit, runs structkit list, generates from .struct.yaml with --non-interactive, and opens a pull request with whatever changed.
The pull request part is the design decision worth noticing. It does not commit to your default branch. It opens a PR on a branch named repository-dispatch/update-<run_id>, labelled automated-pr, and a human decides. Automation that merges itself into twenty repositories is a worse risk than the drift it fixes.
Every input
| Input | Default | Purpose |
|---|---|---|
struct_file | .struct.yaml | Definition to generate from |
output_dir | . | Where to generate |
args | (empty) | Extra arguments passed to generate |
pr_title | Run StructKit generate on repository | PR title and commit message |
pr_body | (boilerplate) | PR description |
pr_assignees | (empty) | Comma-separated assignees |
pr_base | main | Base branch |
runs_on | ubuntu-latest | Runner label |
custom_structure_repository | (empty) | Repository holding your definitions |
custom_structure_path | structures | Subdirectory within it |
version | main | StructKit version to install |
A realistic configuration:
name: sync-platform-standards
on:
schedule:
- cron: "0 9 * * 1"
workflow_dispatch:
jobs:
generate:
uses: httpdss/structkit/.github/workflows/struct-generate.yaml@main
with:
custom_structure_repository: acme/platform-structures
custom_structure_path: structures
args: "-f skip"
pr_title: "chore: sync repository to platform standards"
pr_body: |
Automated sync from acme/platform-structures.
Review the diff — nothing here was written by a human.
pr_assignees: platform-team
version: "3.2.1"
secrets:
token: ${{ secrets.STRUCT_RUN_TOKEN }}Pin version. The default is main, which installs straight from the Git repository. A scheduled job whose tool changes underneath it is a job that will eventually surprise you at 9am on a Monday.
Set args deliberately. Without it you get the default overwrite strategy. -f skip adds only what is missing, which is the right first pass against repositories you did not write.
The token needs write access to contents and pull requests. When custom_structure_repository is private, the same token has to be able to read it.
The variable that hangs your build
Variables declared without a default prompt interactively. In CI there is nobody to type, so the job blocks until the runner times out — typically six hours, with no useful output.
Two defences, and use both:
- name: Generate
env:
STRUCTKIT_NON_INTERACTIVE: "true"
run: |
structkit generate -v "service_name=billing-api,owning_team=payments" .struct.yaml .-v supplies the values; STRUCTKIT_NON_INTERACTIVE turns a missing one into a loud failure instead of a hang.
The trap: only true, 1, and yes are recognised, case-insensitively. Everything else is false — including "on", "enable", and "true " with a trailing space, which is easy to produce from a YAML block scalar. If your job hangs despite setting it, check for whitespace.
Environment variables worth setting in CI
All of these set defaults that command-line arguments override.
| Variable | Why in CI |
|---|---|
STRUCTKIT_NON_INTERACTIVE | Fail instead of hang |
STRUCTKIT_FILE_STRATEGY | Set skip or backup as a safety default |
STRUCTKIT_BACKUP_PATH | Where -f backup writes |
STRUCTKIT_STRUCTURES_PATH | Definitions directory, without threading -s everywhere |
STRUCTKIT_LOG_LEVEL | DEBUG when a job misbehaves |
STRUCTKIT_OUTPUT_MODE | console to render without writing |
STRUCTKIT_HTTP_TIMEOUT | Raise from the 10s default on slow runners |
STRUCTKIT_HTTP_RETRIES | Raise from 2 for flaky egress |
STRUCTKIT_DENY_NETWORK | 1 where HTTP is blocked but git works |
GITHUB_TOKEN | Private repos, and authenticated API rate limits |
Precedence is arguments, then environment, then built-in defaults.
GITHUB_TOKEN matters more than it looks if your definitions use latest_release. The anonymous GitHub API rate limit is low, results are cached for only ten minutes and keyed on the arguments, and a definition pinning several actions will exhaust it — producing LATEST_RELEASE_ERROR in generated files rather than a hard failure. Which is exactly why the next section exists.
Guard the output
Because failures are designed to be visible rather than fatal, a job that generates files should check them:
- name: Generate
env:
STRUCTKIT_NON_INTERACTIVE: "true"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: structkit generate -f skip .struct.yaml .
- name: Fail on unresolved template errors
run: |
if grep -rn "LATEST_RELEASE_ERROR" --exclude-dir=.git .; then
echo "::error::Version resolution failed — refusing to commit"
exit 1
fiLATEST_RELEASE_ERROR is what the filter returns when it cannot resolve a release and cannot fall back. It is a deliberate loud marker — StructKit will not silently substitute an unrelated revision — and catching it before the commit step is the whole point of the design.
Drift detection as a separate job
Generation-with-a-PR is the correction. Sometimes you want the check on every pull request instead:
name: structure-drift
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
cache: 'pip'
- run: pip install structkit==3.2.1
- name: Validate definitions
run: |
find . -name '.struct.yaml' -o -name '*.struct.yaml' \
| xargs -r -n1 structkit validate
- name: Check for drift
env:
STRUCTKIT_NON_INTERACTIVE: "true"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
structkit generate -d --diff .struct.yaml . | tee drift.txt
if [ -s drift.txt ]; then
echo "::warning::Repository has drifted from platform standards"
{
echo "## Structure drift"
echo '```diff'
cat drift.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
fi-d writes nothing, --diff prints what would change. An empty diff means compliant.
Two notes. structkit validate checks that a definition file is well-formed — it does not check a repository against one, which is why the drift step exists separately. And start this as a warning: a gate that blocks merges before anyone has converged mostly teaches people to route around it.
Docker
For runners without Python, or to pin the whole toolchain:
docker run \
-v $(pwd):/workdir \
-u $(id -u):$(id -g) \
ghcr.io/httpdss/structkit:main generate \
file:///workdir/.struct.yaml /workdir/output-u $(id -u):$(id -g) is not optional. Without it the container runs as root and every generated file is owned by root — which on a self-hosted runner leaves a workspace the next job cannot clean up, and locally leaves files you need sudo to delete.
Paths inside the container are container paths. /workdir is the mount point, so the definition is file:///workdir/.struct.yaml, not a host path.
An :alpine tag is published alongside :main. Environment variables pass through with -e:
docker run \
-e STRUCTKIT_NON_INTERACTIVE=true \
-e STRUCTKIT_FILE_STRATEGY=backup \
-e STRUCTKIT_BACKUP_PATH=/workdir/.backup \
-e GITHUB_TOKEN \
-v $(pwd):/workdir \
-u $(id -u):$(id -g) \
ghcr.io/httpdss/structkit:main generate /workdir/.struct.yaml /workdir/output-e GITHUB_TOKEN without a value forwards it from the host environment, which keeps the token out of your shell history and the process list.
Pin the tag in anything scheduled. :main moves.
Other CI systems
Nothing here is GitHub-specific apart from the reusable workflow.
GitLab CI:
sync-standards:
image: python:3.12-slim
variables:
STRUCTKIT_NON_INTERACTIVE: "true"
before_script:
- pip install structkit==3.2.1
script:
- structkit generate -d --diff .struct.yaml . | tee drift.txt
- test ! -s drift.txt || echo "Drift detected"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"Any container-based runner can use the Docker invocation above directly.
The pattern generalizes: install pinned, set STRUCTKIT_NON_INTERACTIVE, supply variables with -v, preview with -d --diff, and decide whether the output becomes a warning, a failure, or a pull request.
Validating the definitions repository itself
If you maintain structures centrally, that repository needs its own pipeline. Definitions are code.
name: validate-structures
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: '3.12'
- run: pip install structkit==3.2.1
- name: Validate every definition
run: find structures -name '*.yaml' | xargs -n1 structkit validate
- name: Smoke-generate each service template
env:
STRUCTKIT_NON_INTERACTIVE: "true"
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for s in service/python service/go terraform/module; do
structkit generate -s ./structures -d --diff \
-v "service_name=smoke,owning_team=platform,module_name=smoke" \
"$s" /tmp/smoke || exit 1
done
- name: Show rendered output for review
env:
STRUCTKIT_NON_INTERACTIVE: "true"
run: |
structkit generate -s ./structures -o console \
-v "service_name=smoke,owning_team=platform" \
service/python /tmp/smoke >> "$GITHUB_STEP_SUMMARY"That last step is the one teams skip and then wish they had. -o console renders the template into the job summary, so a reviewer sees what a template change actually produces instead of reconstructing it mentally from a YAML diff.
Then tag the repository. Consumers pin to tags, so a tag is your release boundary, and bumping a service from @v1.4.0 to @v1.5.0 becomes a visible reviewable act rather than something that happens overnight.
Making runs reproducible
A CI run should not depend on how anyone's machine is configured. Pin the source inside the committed definition:
sources:
platform:
url: github://acme/platform-structures@v1.4.0/structures
folders:
- ./:
struct: platform/service/python
with:
service_name: billing-api
owning_team: paymentsBranch refs are checked out from origin/<branch>; tags and commit SHAs are checked out detached and never git pull. That mechanical difference is why a tag-pinned source is reproducible and a branch-tracking one is not.
Three things to pin, and they are separate: the StructKit version in the install step, the source ref in the definition, and the container tag if you use Docker. Pinning two out of three still leaves a moving part.
Wrap-up
The parts that actually bite: STRUCTKIT_NON_INTERACTIVE accepting only true/1/yes and hanging the build otherwise, Docker leaving root-owned files without -u, LATEST_RELEASE_ERROR landing in a commit because nothing checked for it, and version: main changing your tooling on a schedule.
Get those right and the useful pattern falls out: a weekly job that regenerates from a pinned definition and opens a pull request, plus a per-PR drift check that starts as a warning. Standards drift becomes a review queue rather than an audit.
See GitHub Integration, Environment Variables, and Installation for the Docker images.