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
In Terrarium
.github/workflows/gate.yml runs the in CI, and its whole job is a checkout and one call:
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,
secretsis empty; pass what a step needs as an input, asgithub-tokenis 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
- LessonMerge gate
- LessonPull requests
- LessonAuthor trust
- LessonCloud sessions
- LessonDependabot grouping
Last updated · Latest source on GitHub ↗