Skip to content

Hands-on: exercises in sharifndlovu123/github-actions-course (authoring-actions and developer-experience modules). Concepts first: GitHub Actions Deep Dive.

Pushing a commit just to see whether a workflow works is slow. This note covers building your own actions and iterating locally.

Ways to reuse automation

Option What it is Best for
Composite action A bundle of steps in an action.yml Repeated step sequences in one repo or org
Reusable workflow A whole workflow called with workflow_call Shared pipelines (build + test + deploy)
JavaScript / TypeScript action Node code run by the runner Fast, cross-platform logic; GitHub API calls
Container action A Docker image (or Dockerfile) run as a step Any language or tool; Linux runners only
flowchart TD
    A[Need to reuse logic?] --> B{Just a few shell steps?}
    B -- yes --> C[Composite action]
    B -- no --> D{Whole pipeline with jobs?}
    D -- yes --> E[Reusable workflow]
    D -- no --> F{Need a specific runtime or tools?}
    F -- "Node, fast start" --> G[JavaScript / TypeScript action]
    F -- "any language / system tools" --> H[Container action]

Composite Action

# .github/actions/greet/action.yml
name: Greet
description: Print a greeting and return a random number
inputs:
  who:
    description: Name to greet
    default: world
outputs:
  number:
    description: A random number
    value: ${{ steps.rand.outputs.number }}
runs:
  using: composite
  steps:
    - run: echo "Hello, ${{ inputs.who }}"
      shell: bash                       # shell is required in composite steps
    - id: rand
      run: echo "number=$RANDOM" >> "$GITHUB_OUTPUT"
      shell: bash
# calling it
- uses: ./.github/actions/greet
  with:
    who: Sharif

Reusable Workflows

# .github/workflows/build.yml  (the source)
on:
  workflow_call:
    inputs:
      environment: { type: string, required: true }
    secrets:
      deploy_token: { required: true }
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - run: echo "Building for ${{ inputs.environment }}"
# caller
jobs:
  call-build:
    uses: ./.github/workflows/build.yml          # same repo
    with:
      environment: staging
    secrets:
      deploy_token: ${{ secrets.DEPLOY_TOKEN }}
  call-shared:
    uses: my-org/shared/.github/workflows/build.yml@<commit-sha>   # another repo, pinned
    secrets: inherit

Version shared workflows with tags or SHAs, and call them by a pinned reference.


JavaScript and TypeScript Actions

Three approaches appeared in the course:

Approach Trade-off
Vanilla Node, dependencies committed No build step, but a large node_modules in the repo
Node action with a build step Bundle with ncc or esbuild into dist/ and commit it
TypeScript, compiled before use Type safety; compile and commit the output (or build in CI)
# action.yml
name: My JS action
runs:
  using: node20
  main: dist/index.js
import * as core from '@actions/core'
async function run() {
  const who = core.getInput('who')
  core.info(`Hello ${who}`)
  core.setOutput('time', new Date().toISOString())
}
run().catch(e => core.setFailed(e.message))

GitHub's actions/typescript-action template gives a ready structure with tests and packaging.


Container Actions

Variant Behaviour
Dockerfile in the repo Built every run, so slower
Prebuilt public image (image: docker://...) Pulled, not built; much faster
Language-specific (shell, Python) Entry point script inside the image
runs:
  using: docker
  image: Dockerfile        # or docker://ghcr.io/me/greeter:1.0
  args: ["${{ inputs.who }}"]

Container actions run only on Linux runners. Publish a versioned image when startup time matters.


Developer Experience: Test Before You Push

1. Move logic out of the YAML

The best fix is to keep workflows thin and put the real work in scripts or a task runner (Task, Make, shell scripts, a small CLI). Then:

- run: task test

You can run task test on your laptop, so most logic is tested without GitHub at all. Keep only Git and GitHub-specific glue (checkout, auth, uploading results) in the workflow.

2. Test actions

  • Write a normal test suite (npm test, Jest)
  • Run the action locally with GitHub's local-action tool against a .env file of inputs:
$ npx @github/local-action . src/main.ts .env

3. Run workflows locally with act

act runs workflows in Docker containers and can simulate events:

$ act workflow_dispatch -W .github/workflows/hello.yaml
$ act pull_request -s MY_SECRET=value --container-architecture linux/amd64
$ act -l                       # list jobs

Limits: the runner image is not identical to GitHub's, some contexts and services differ, and OIDC and some marketplace actions will not work. Treat act as a fast first check, not the final test.

4. Breakpoints on the real runner

GitHub-hosted runners offer no shell access by default. Add an SSH or tmate step temporarily to debug, or use a third-party runner service that provides built-in breakpoints:

- uses: mxschmitt/action-tmate@<sha>
  if: failure()
  with:
    limit-access-to-actor: true       # only you can connect

Remove it afterwards: an open debug session is a way into the runner and its secrets.

5. Debug logging

Set repository secret or variable ACTIONS_STEP_DEBUG=true (and ACTIONS_RUNNER_DEBUG=true) for verbose logs, and re-run failed jobs from the Actions tab.


Checklist for a New Action

  • [ ] Clear inputs, outputs and defaults documented in the README
  • [ ] Least privilege: ask for no more token permissions than needed
  • [ ] Tests, and a local way to run it
  • [ ] Versioned releases (tags and a moving major tag such as v1) with a changelog
  • [ ] Pinned dependencies and dist/ committed for JavaScript actions
  • [ ] Never prints secrets; uses ::add-mask:: for generated sensitive values