GitHub Actions style guide
How to write a workflow. What each existing workflow does is in CI workflows.
Creating workflows
Workflows live under .github/workflows/. DO NOT add a workflow for one project
(BUILD-25).
A name: starts with its stage, then -, then what the stage does not already say:
1 Check - Pre-commit, 2 Build - Base images, Ops - DB migrate. The stages are in
delivery pipeline. Add a description: saying what it does.
The file name is the stage word and the rest of the name, in lowercase with hyphens:
1 Check - Image pins lives in check-image-pins.yml, Ops - DB migrate in ops-db-migrate.yml.
Keep the rest of the name short enough to make a readable file name, and put details in the
description:.
Trigger a check workflow on pull_request (opened, reopened, synchronize) and on pushes to
main. A check that is or will be required MUST NOT have a paths filter. A skipped required
check never reports and blocks the merge. Any other check MAY take a paths list naming the
workflow file and its own files, plus '!**/*.md'. Add workflow_dispatch where a manual run is
useful.
Run jobs on ubuntu-latest, or ubuntu-latest-4-cores where a job needs it. Name them check,
build and docker.
pull_request:
types: [opened, reopened, synchronize]
paths:
- '.github/workflows/my-workflow.yml'
- '!**/*.md'
push:
branches: [main]
paths:
- '.github/workflows/my-workflow.yml'
- '!**/*.md'
Workflow inputs
workflow_dispatch supports type: choice, which renders a dropdown:
workflow_dispatch:
inputs:
environment:
description: Environment to deploy to
type: choice
options: [devel, staging, prod]
required: true
workflow_call does not, so
an enum arrives as a free string. Add a validate-inputs job that rejects an unknown value. Every
job using the input needs: it:
- name: Validate environment
run: |
case "${{ inputs.environment }}" in
devel|staging|prod) ;;
*) echo "::error::Invalid environment: ${{ inputs.environment }}"; exit 1 ;;
esac
Use the ::error:: prefix, or GitHub renders the failure as ordinary log output.
A workflow supplies credentials, not logic
A workflow provides a trigger, a runner and credentials. The work is a Mise task:
- name: Push the image
run: mise run //croesus:push-image
Inline docker build, docker push, go build or uv sync is a defect
(BUILD-2). Anything CI can do that you cannot run locally
is untested, and the pipeline cannot leave GitHub Actions without moving build logic. It is also
how five build workflows came to carry five copies of one setup job, two of them quietly
broken.
A run: block longer than a few lines, or containing a conditional, belongs in a script under
mise/scripts/ where shellcheck can see it
(SHELL-2).
Authentication
- Authenticate to GCP through the shared
.github/actions/set-up-gcloudcomposite. It derives the WIF provider and CI service account from the project id and number. A workflow therefore needs noLYC_*variables of its own, and onlyid-token: write. A workflow with its own identity (SECURITY-5) passes it asservice_account. - A long-lived service-account key MUST NOT be used (SECURITY-1).
- The credential that pushes an image also pulls the base images it builds on. Do not add a second one for convenience (SECURITY-5).
- A workflow that opens a pull request MUST authenticate as a GitHub App. A pull request opened
with
GITHUB_TOKENtriggers no checks, so it would be merged unverified.
Installing tools
Install Mise with the shared .github/actions/set-up-mise composite, the one place that pins
jdx/mise-action and Mise's version. Pass install: false to skip the tools. Set
MISE_ENABLE_TOOLS on the job to install only some of them. Tool versions then come from
mise.toml and mise.lock. Do not pipe an installer script. Do not pin a tool version in a workflow either: that
is a second place for it to drift (BUILD-10).
Composite actions
Reusable actions live in .github/actions/. A value that changes periodically but must be the same
for every caller is not an input. Set it as a constant the action reads:
using: composite
steps:
- name: Set constants
id: constants
shell: bash
run: |
echo "my_package_version=1.2.0" >> "$GITHUB_OUTPUT"
Freestanding actions
lyceum-tech/checkout-lyceum@main checks out the monorepo. Use it rather than actions/checkout:
- name: Check out code
uses: lyceum-tech/checkout-lyceum@main
Third-party actions
A third-party action is a supply-chain risk. Only allowlisted actions are permitted. That is unenforced, because GitHub does not offer the allowlist for private repositories, so be careful. Pin a non-Lyceum action to a commit SHA with the version in a trailing comment (BUILD-10):
astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6
Workflow environments and secrets
Define variables and secrets at the organisation level. Ask an
administrator. Prefix variables with LYC_. The set-up-env action
exports them into the environment:
- name: Set up environment
uses: ./.github/actions/set-up-env
with:
vars: ${{ toJSON(vars) }}
secrets: ${{ toJSON(secrets) }}
Known quirks
github.workspaceis unreliable in containerised workflows, and especially inside a composite action. Do this once, then use$GITHUB_WORKSPACEorenv.GITHUB_WORKSPACE:- name: Set GITHUB_WORKSPACE shell: bash run: | echo "GITHUB_WORKSPACE=$GITHUB_WORKSPACE" >> $GITHUB_ENV