Back to Blog

    Running StructKit in CI: Actions, Docker, and Non-Interactive Mode

    August 20, 2026Kenny Belitzky

    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

    InputDefaultPurpose
    struct_file.struct.yamlDefinition to generate from
    output_dir.Where to generate
    args(empty)Extra arguments passed to generate
    pr_titleRun StructKit generate on repositoryPR title and commit message
    pr_body(boilerplate)PR description
    pr_assignees(empty)Comma-separated assignees
    pr_basemainBase branch
    runs_onubuntu-latestRunner label
    custom_structure_repository(empty)Repository holding your definitions
    custom_structure_pathstructuresSubdirectory within it
    versionmainStructKit 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.

    VariableWhy in CI
    STRUCTKIT_NON_INTERACTIVEFail instead of hang
    STRUCTKIT_FILE_STRATEGYSet skip or backup as a safety default
    STRUCTKIT_BACKUP_PATHWhere -f backup writes
    STRUCTKIT_STRUCTURES_PATHDefinitions directory, without threading -s everywhere
    STRUCTKIT_LOG_LEVELDEBUG when a job misbehaves
    STRUCTKIT_OUTPUT_MODEconsole to render without writing
    STRUCTKIT_HTTP_TIMEOUTRaise from the 10s default on slow runners
    STRUCTKIT_HTTP_RETRIESRaise from 2 for flaky egress
    STRUCTKIT_DENY_NETWORK1 where HTTP is blocked but git works
    GITHUB_TOKENPrivate 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
        fi

    LATEST_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: payments

    Branch 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.