Skip to content

dequelabs/axe-api-team-public

Repository files navigation

axe-api-team-public

Shared configuration, settings, and reusable GitHub Actions for the Axe API Team.

This repository is the team's central library of GitHub Actions. Other repos consume these actions by referencing them at dequelabs/axe-api-team-public/.github/actions/<action>@main, and inherit shared repo settings via Probot Settings.

Repository structure

.github/
  actions/<action-name>-vN/   # one directory per action (see Directory layout)
  workflows/                  # CI for this repo (lint, typecheck, per-action tests, build sync)
  settings.yml                # Probot Settings consumed by other repos via `_extends`
scripts/
  build-action.mjs            # shared esbuild bundler used by every Node action's `build`
package.json                  # npm workspaces root (`.github/actions/*`); shared dev dependencies
tsconfig.json                 # base TypeScript config that each action extends

The root package.json defines npm workspaces over .github/actions/*. Shared dependencies (@actions/core, @actions/github, @actions/exec, semver, …) live in the root and are hoisted to every action, then bundled into each action's dist/index.js at build time — so actions run with no npm install step. An action that needs its own one-off dependency can declare it in its workspace package.json (e.g. checkov-scans-v1). If a breaking change to an action also needs a breaking dependency bump, install the old version under an alias (e.g. npm install pkg-v1@npm:pkg@1) alongside the new one.

Two ways to build an action

Every action is one of two kinds. Pick based on what the action does, not how big it is.

1. Composite action (pure YAML)

Just an action.yml with runs.using: 'composite' and a list of steps. No source code, no build. Use this when the action is orchestration or thin shell scripting:

  • It chains other actions (ours and third-party) together.
  • It runs git / gh CLI commands with little or no data transformation.
  • It invokes a caller-supplied script and just passes inputs through.

A thin git wrapper — get-latest-and-previous-tags-v1:

runs:
  using: 'composite'
  steps:
    - name: Get latest and previous tags
      id: get-tags
      shell: bash
      run: |
        echo "latest-tag=$(git describe --abbrev=0 --tags)" >> $GITHUB_OUTPUT
        echo "previous-tag=$(git describe --abbrev=0 --tags $(git rev-list --tags --skip=1 --max-count=1))" >> $GITHUB_OUTPUT

Orchestrating other actions — move-released-issues-and-create-sync-pr-v1:

runs:
  using: 'composite'
  steps:
    - uses: dequelabs/axe-api-team-public/.github/actions/generate-commit-list-v1@main
      id: get-commit-list
      with: { tag: ${{ steps.get-previous-tag.outputs.tag }} }
    - uses: dequelabs/axe-api-team-public/.github/actions/label-and-move-released-issues-v1@main
      with: { commit-list: ${{ steps.get-commit-list.outputs.commit-list }}, token: ${{ inputs.token }} }

Other examples: cancel-if-release-pr-exists, create-release-candidate-v1 (long but still pure orchestration of other actions).

See GitHub's composite action docs for the full syntax.

2. Node action (TypeScript)

A TypeScript project compiled to a single bundled dist/index.js that the runner executes via runs.using: 'node24'. Use this when the action carries real logic that deserves unit tests:

  • It calls the GitHub API — via github.getOctokit(...) (REST or GraphQL) or by wrapping the gh CLI and parsing its JSON — with non-trivial queries or mutations (e.g. project-board field mapping).
  • It parses or transforms data (e.g. parsing semantic commit messages, extracting PR numbers).
  • It implements branchy business logic worth testing in isolation (e.g. "should we auto-release?").

A Node action's action.yml just points at the bundled entry — get-package-version-v1:

runs:
  using: 'node24'
  main: 'dist/index.js'

Examples: move-issues-based-on-label-v1 (GraphQL project queries), add-to-board-v1 (wraps the gh CLI and parses its JSON), generate-commit-list-v1 (commit parsing), has-auto-releasable-commits-v1 (release-policy logic).

Choosing between them

There is no single rule that always gives the right answer — it is a trade-off. Prefer the simplest approach that fits the situation, and weigh the following.

A composite (YAML) action runs in the caller's job context — its tools, Node version, and environment are the caller's. That makes it debuggable and fixable on the spot, and steps stay visible and overridable without rebuilding a bundle. It fits orchestration and shell work that gh, git, jq, or yq already do well.

A Node (TypeScript) action runs in its own isolated process with its own bundled dependencies, so it behaves the same everywhere and is highly reproducible. Our established test infrastructure (Node test runner, 100% coverage) is TypeScript-only today, so complex logic that needs unit tests is easier to maintain here.

Isolation cuts both ways. That same isolation removes the caller's ability to adjust behavior when something breaks. For example, update-axe-core-v1 installs dependencies, and in some repos that triggered Playwright to download browsers, hitting a Node bug that stalled archive extraction — with no escape hatch for the caller. The remedy is a composite variant that runs in the caller's Node context and hands them control. During a wide, multi-repo release push, "fixable on the spot" can matter more than "perfectly idempotent."

No single signal decides it: gh covers most GitHub API calls from a shell, jq/yq cover JSON/YAML, and behavior can be tested in any language — so "uses the API", "parses structured data", or "needs tests" are not on their own reasons to reach for Node. Size isn't the deciding factor either: a large script should be broken into smaller, focused scripts, just as a large Node action is split into small modules. Choose the approach — and the abstraction layer — that makes the action easiest to run and maintain in the environments that call it.

Creating a new action

Directory layout & naming

Each action lives in its own directory under .github/actions/, named <action-name>-vN where N is the major version. Breaking changes ship as a new directory (...-v2) so existing callers pinned to ...-v1@main keep working — we never branch per action.

my-new-action-v1/
  action.yml          # both kinds: metadata + inputs/outputs
  README.md           # both kinds: description, inputs/outputs table, permissions, usage example
  # — Node actions also have: —
  package.json        # workspace scripts (build/test/typecheck/lint)
  tsconfig.json       # extends ../../../tsconfig.json
  node.config.json    # Node test-runner options (tsx loader, 100% coverage thresholds)
  src/
    index.ts          # entry point — wires deps into run()
    run.ts            # reads inputs, orchestrates, sets outputs
    <feature>.ts      # one file per unit of logic…
    <feature>.test.ts # …each with a colocated test
    types.ts
  dist/index.js       # GENERATED by the build — must be committed

Inputs and outputs are always declared in action.yml:

inputs:
  project-number:
    description: 'The project board number'
    required: false
    default: '188'
outputs:
  version:
    description: 'The extracted version'

The Node action source pattern

Keep index.ts a thin entry point: it wires the real @actions/core and @actions/github and calls run(core, github). The action's logic lives in run.ts as an exported handler that takes core/github as parameters. The runtime auto-executes the entry (main: dist/index.js), while tests import the handler without running it and invoke it repeatedly with different inputs — two different responsibilities, hence two files:

// src/index.ts
import * as core from '@actions/core'
import * as github from '@actions/github'
import run from './run'

run(core, github)

run.ts follows a fixed shape. Validate inputs, apply defaults, and fail fast at the top of the function, before any real work — read every input, reject the bad ones with core.setFailed(...) followed by an early return, and resolve defaults. Only once everything is known-good should the action call its helpers. Wrap the whole body in a single try/catch that ends in core.setFailed so any thrown error fails the step with a message instead of an opaque stack trace. set-date-closed-field-v1/src/run.ts is the reference for this layout.

// src/run.ts — validate & default first, then orchestrate small helpers
export default async function run(core: Core, github: Github): Promise<void> {
  try {
    // 1) Validate inputs and apply defaults FIRST — fail fast with a clear reason.
    const token = core.getInput('token')
    if (!token) {
      core.setFailed('`token` input is not set')
      return
    }

    const issueNumber = parseInt(
      core.getInput('issue-number', { required: true })
    )
    if (isNaN(issueNumber)) {
      core.setFailed('`issue-number` must be a number')
      return
    }

    const fieldName = core.getInput('date-field-name').trim() || 'DateClosed'

    // 2) Do the work, logging each stage (see Logging below).
    core.info(`Looking up issue #${issueNumber} for field "${fieldName}"`)
    const result = await updateDateField(/* … */)

    // 3) Surface results as outputs.
    core.setOutput('updated', String(result.updated))
  } catch (error) {
    core.setFailed(
      `Action failed: ${error instanceof Error ? error.message : String(error)}`
    )
  }
}

Split each distinct step into its own <feature>.ts with a colocated <feature>.test.ts (see generate-commit-list-v1/src for the example). Tests must hit 100% coverage of src (lines, branches, functions). Coverage thresholds and exclusions are set per action in its node.config.json (commonly index.ts and *.test.ts, and sometimes types.ts).

Tests use Node's built-in runner with mock.module to stub imports (@actions/exec, the GitHub client, …), then await import('./run.ts') and call run with lightweight core/github stubs — see generate-commit-list-v1/src/run.test.ts.

Logging (required)

Every Node action must log its progress with core.info so that a failed run shows which stage it reached, where it stopped, and why. Add a log line before each meaningful step (input resolved, API call, decision branch, final result) — the run log is the only window into a failed action. Use core.warning for recoverable oddities and core.setFailed (with a specific message) for anything that should fail the step.

core.info(`Issue state: "${issue.state}", closed_at: "${issue.closed_at}"`)
if (!issue.closed_at) {
  core.info(`Issue #${issueNumber} is not closed — nothing to do`)
  return
}
core.info(`Updating "${fieldName}" to ${dateString}`)

Never log sensitive data. Tokens, secrets, credentials, and full auth headers must never be written to logs or error messages — log identifiers (issue numbers, URLs, field names, counts) instead. GitHub automatically masks values that came from ${{ secrets.* }}, showing them as *** in the run log, but that masking is a safety net, not permission: keep secrets and tokens out of your core.info / core.setFailed strings entirely.

For values that are not ${{ secrets.* }} but still should not leak (e.g. auto-generated tokens), call core.setSecret(value) to register them for masking (::add-mask::) so the runner redacts them from logs.

Build (Node actions only)

npm run build runs the shared scripts/build-action.mjs, which uses esbuild to bundle src/index.tsdist/index.js (single ESM file, node24 target) and writes a dist/licenses.txt summary. dist/ is committed — the runner executes the committed bundle, not your source. The update-generated-files workflow also rebuilds and opens a sync PR if dist drifts, but always commit your own build output.

Testing & checks before you open a PR

Run these from the action's directory (or use --workspace=<action-name> from the root). CI (.github/workflows/tests.yml) runs the same on every PR:

npm run lint        # eslint
npm run lint -ws    # from the repo root (no aggregate `lint` script there)
npm run typecheck   # tsc --noEmit
npm test            # Node's built-in test runner; enforces 100% coverage
npm run build       # rebuild dist/ — then commit it
  • Composite actions have no unit tests; verify them by running the workflow that uses them (or a throwaway workflow on a branch). Still run lint/typecheck at the root.
  • After any change to a Node action's src/, re-run npm test and npm run build, then commit the updated dist/index.js. A stale dist is the most common review catch.

Probot Settings

Where it is enabled, Probot Settings manages repo and branch-protection settings from .github/settings.yml (see this repo's own file for teams and branch protection). Note that Probot does not manage repository rulesets — those are tracked separately.

A consuming repo extends this repo's settings by creating a .github/settings.yml that uses _extends:

_extends: 'dequelabs/axe-api-team-public'

About

Shared configuration, settings, and actions for the Axe API Team

Resources

License

Code of conduct

Stars

0 stars

Watchers

2 watching

Forks

Packages

 
 
 

Contributors