Skip to content

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-gcloud composite. It derives the WIF provider and CI service account from the project id and number. A workflow therefore needs no LYC_* variables of its own, and only id-token: write. A workflow with its own identity (SECURITY-5) passes it as service_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_TOKEN triggers 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.workspace is unreliable in containerised workflows, and especially inside a composite action. Do this once, then use $GITHUB_WORKSPACE or env.GITHUB_WORKSPACE:
    - name: Set GITHUB_WORKSPACE
      shell: bash
      run: |
        echo "GITHUB_WORKSPACE=$GITHUB_WORKSPACE" >> $GITHUB_ENV