Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

Git & GitHubLesson

Composite actions

A GitHub Actions workflow can hold every step of a CI job, but it doesn't have to. Terrarium keeps its workflow file to a few lines and moves the steps into a composite action: a cloud session can push the action, but not the workflow.

Know composite actions already? Skip to In Terrarium.

Outside the box · General

The concept

A GitHub Actions workflow is a YAML file in .github/workflows/. It says when CI runs (its triggers, such as pull_request), what its token may do (permissions), and which jobs and steps run.

A composite action bundles a list of steps into one step. It is a folder with an action.yml whose runs.using is composite, and each of its run steps names its shell. It can live in the repository that uses it, and GitHub suggests .github/actions/. A workflow calls it by its path, uses: ./.github/actions/<name>, after a checkout has put that folder on the runner. A composite action can't read the secrets context, so a workflow that needs a secret inside it passes the secret in as an input.

The two files are guarded differently. A token needs GitHub's workflow scope to add or change a file in .github/workflows/; other files, including .github/actions/, need only ordinary write access. The scope is described in GitHub Docs: Scopes for OAuth apps.

Full referenceGitHub Docs: Creating a composite action

Looking inside

In Terrarium

.github/workflows/gate.yml runs the in CI, and its whole job is a checkout and one call:

.github/workflows/gate.yml ↗15–33as of
15  gate:
16    runs-on: ubuntu-latest
17    permissions:
18      contents: read
19      pull-requests: write
20    steps:
21      - uses: actions/checkout@v7
⋯      # …
31      - uses: ./.github/actions/gate
32        with:
33          github-token: ${{ secrets.GITHUB_TOKEN }}

The workflow keeps only the triggers, the permissions, the runner, a full-history checkout and the token. Everything else is in .github/actions/gate/action.yml: installing Node and pnpm, the dependencies, the step that decides whether the slow checks may be skipped, each check as its own named step, and a last step that comments on the PR when the Gate is green (Pull requests says who waits for that comment). The comment step skips a PR from a fork and can't turn the Gate red.

A cloud session's token lacks the workflow scope; a local session pushes with the owner's key, which has it. Cloud sessions shows the hook that denies a cloud session's workflow edit, and how such a change goes to a person as a instead.

The same step list also lives in the gate script in package.json and the tier lists in scripts/gate/gate.ts. tests/unit/gate-parity.spec.ts fails when these and the action run different steps, in a different order, or with different skip conditions. Merge gate explains the tiers and the skip.

Why it's built this way

  • Only the fenced part stays in the workflow. A cloud session can't push .github/workflows/, so with every step there, each Gate change waited for a person to copy YAML by hand. The fence covers that folder only, so setup, install and the steps moved to the action. The cost: a trigger or permission change from a cloud session goes to a person as a Proposal. ADR-0026 (docs/adr/0026-gate-workflow-thin-shell.md).
  • No new hole. Moving the steps into a pushable file opens nothing: what CI runs was already set by pushable files such as package.json, and CI runs the PR's own copy of each. A person reviewing the merge is what guards the Gate. ADR-0026 (docs/adr/0026-gate-workflow-thin-shell.md).
  • One list, checked by a test. Steps split across the push fence drifted, so CI checked less than the local Gate; Merge gate gives this reason and the parity test's blind spot.

What to watch for

  • Pushable is not mergeable. Both files are on the list in .claude/rules/adr-0004-human-merge.md, so an agent can change the action, but a person merges the change.
  • CI runs the PR's own copy. A local uses: ./… action comes from the checkout, so a PR that edits the action is checked by its edited version.
  • Secrets stop at the action's edge. Inside a composite action, secrets is empty; pass what a step needs as an input, as github-token is here.

Try it in your project

Move the steps of one CI job into .github/actions/<name>/action.yml and leave the workflow with its triggers, permissions, checkout and a single uses: ./.github/actions/<name>. Then add a test that reads the action and fails when its steps differ from the command you run locally.

Where next

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/git-github/composite-actions