feat(ci): add formatter presets to autofix (#147)

Callers can run prettier, eslint, ruff, and terraform without passing
npm run lint -- --fix, which chains non-fixers in several apps.
This commit is contained in:
Adam Moussa 2026-09-23 23:47:30 +00:00 • committed by GitHub
parent fd41132410
commit 61f15d78b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 125 additions and 21 deletions

View file

@ -9,6 +9,12 @@ name: CI — Autofix
# head, then set output committed=true so the caller skips portions on SHA_old. # head, then set output committed=true so the caller skips portions on SHA_old.
# Do not --no-verify. Do not push to main. # Do not --no-verify. Do not push to main.
# #
# Presets run first, then any format-command / lint-fix-command / extra-command.
# prettier npm ci + npm run format (requires package-lock.json)
# eslint npm ci + npx eslint . --fix (opt-in; do not call npm run lint)
# ruff ruff format . + ruff check --fix . (ruff 0.15.22)
# terraform terraform fmt -recursive in terraform-working-directory
#
# Caller example: # Caller example:
# jobs: # jobs:
# autofix: # autofix:
@ -17,38 +23,52 @@ name: CI — Autofix
# permissions: { contents: write } # permissions: { contents: write }
# secrets: inherit # secrets: inherit
# with: # with:
# format-command: npm run format # presets: prettier,terraform
# lint-fix-command: npm run lint -- --fix #
# Python callers pass presets: ruff,terraform. Add eslint only when that
# repo's CI lint step is ESLint itself and Prettier owns formatting.
# Do not pass `npm run lint -- --fix` (some apps chain Redocly into lint).
# #
# Org secrets (names only): AUTOFMT_APP_ID, AUTOFMT_APP_PRIVATE_KEY. # Org secrets (names only): AUTOFMT_APP_ID, AUTOFMT_APP_PRIVATE_KEY.
on: on:
workflow_call: workflow_call:
inputs: inputs:
format-command: presets:
description: "Write formatter command (e.g. npm run format, ruff format .)" description: "Comma-separated presets: prettier, eslint, ruff, terraform"
type: string type: string
required: true required: false
default: ""
format-command:
description: "Optional write command run after presets (e.g. npm run format)"
type: string
required: false
default: ""
lint-fix-command: lint-fix-command:
description: "Optional write lint-fix command (e.g. ruff check --fix .)" description: "Optional write lint-fix command run after presets"
type: string type: string
required: false required: false
default: "" default: ""
extra-command: extra-command:
description: "Optional extra write command (e.g. terraform fmt -write)" description: "Optional extra write command run after presets"
type: string type: string
required: false required: false
default: "" default: ""
node-version: node-version:
description: "Node.js version when package-lock.json is present" description: "Node.js version for the prettier or eslint preset, or an npm command"
type: string type: string
required: false required: false
default: "24" default: "24"
terraform-version: terraform-version:
description: "Terraform version when extra-command mentions terraform" description: "Terraform version for the terraform preset or an extra-command that runs terraform"
type: string type: string
required: false required: false
default: "1.16.0" default: "1.16.0"
terraform-working-directory:
description: "Directory for the terraform preset (terraform fmt -recursive)"
type: string
required: false
default: "terraform"
outputs: outputs:
committed: committed:
description: "true when this job pushed a formatter commit" description: "true when this job pushed a formatter commit"
@ -105,27 +125,93 @@ jobs:
ref: ${{ github.head_ref }} ref: ${{ github.head_ref }}
persist-credentials: true persist-credentials: true
- name: Resolve presets
id: presets
env:
SKIP_BOT: ${{ steps.skip-bot.outputs.skip }}
PRESETS: ${{ inputs.presets }}
FORMAT_COMMAND: ${{ inputs.format-command }}
LINT_FIX_COMMAND: ${{ inputs.lint-fix-command }}
EXTRA_COMMAND: ${{ inputs.extra-command }}
run: |
set -euo pipefail
write_outputs() {
{
echo "prettier=$1"
echo "eslint=$2"
echo "ruff=$3"
echo "terraform=$4"
} >> "${GITHUB_OUTPUT}"
}
if [ "${SKIP_BOT}" = "true" ]; then
write_outputs false false false false
exit 0
fi
want_prettier=false
want_eslint=false
want_ruff=false
want_terraform=false
if [ -n "${PRESETS}" ]; then
IFS=',' read -ra parts <<< "${PRESETS}"
for raw in "${parts[@]}"; do
token=$(printf '%s' "${raw}" | tr -d '[:space:]')
case "${token}" in
"") ;;
prettier) want_prettier=true ;;
eslint) want_eslint=true ;;
ruff) want_ruff=true ;;
terraform) want_terraform=true ;;
*)
echo "Unknown preset: ${token}" >&2
exit 1
;;
esac
done
fi
if { [ "${want_prettier}" = "true" ] || [ "${want_eslint}" = "true" ]; } && [ ! -f package-lock.json ]; then
echo "prettier and eslint presets require package-lock.json" >&2
exit 1
fi
if [ "${want_prettier}" = "false" ] \
&& [ "${want_eslint}" = "false" ] \
&& [ "${want_ruff}" = "false" ] \
&& [ "${want_terraform}" = "false" ] \
&& [ -z "${FORMAT_COMMAND}" ] \
&& [ -z "${LINT_FIX_COMMAND}" ] \
&& [ -z "${EXTRA_COMMAND}" ]; then
echo "Set presets or a format, lint-fix, or extra command." >&2
exit 1
fi
write_outputs "${want_prettier}" "${want_eslint}" "${want_ruff}" "${want_terraform}"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' }} if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' && (steps.presets.outputs.prettier == 'true' || steps.presets.outputs.eslint == 'true' || contains(inputs.format-command, 'npm') || contains(inputs.lint-fix-command, 'npm')) }}
with: with:
node-version: ${{ inputs.node-version }} node-version: ${{ inputs.node-version }}
cache: npm cache: npm
- name: Install npm dependencies - name: Install npm dependencies
if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' }} if: ${{ steps.skip-bot.outputs.skip != 'true' && hashFiles('package-lock.json') != '' && (steps.presets.outputs.prettier == 'true' || steps.presets.outputs.eslint == 'true' || contains(inputs.format-command, 'npm') || contains(inputs.lint-fix-command, 'npm')) }}
run: npm ci run: npm ci
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
if: ${{ steps.skip-bot.outputs.skip != 'true' && (contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff')) }} if: ${{ steps.skip-bot.outputs.skip != 'true' && (steps.presets.outputs.ruff == 'true' || contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff') || contains(inputs.extra-command, 'ruff')) }}
with: with:
python-version: "3.12" python-version: "3.12"
- name: Install ruff - name: Install ruff
if: ${{ steps.skip-bot.outputs.skip != 'true' && (contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff')) }} if: ${{ steps.skip-bot.outputs.skip != 'true' && (steps.presets.outputs.ruff == 'true' || contains(inputs.format-command, 'ruff') || contains(inputs.lint-fix-command, 'ruff') || contains(inputs.extra-command, 'ruff')) }}
run: pip install 'ruff==0.15.22' run: pip install 'ruff==0.15.22'
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
if: ${{ steps.skip-bot.outputs.skip != 'true' && contains(inputs.extra-command, 'terraform') }} if: ${{ steps.skip-bot.outputs.skip != 'true' && (steps.presets.outputs.terraform == 'true' || contains(inputs.extra-command, 'terraform')) }}
with: with:
terraform_version: ${{ inputs.terraform-version }} terraform_version: ${{ inputs.terraform-version }}
terraform_wrapper: false terraform_wrapper: false
@ -133,12 +219,32 @@ jobs:
- name: Apply formatter - name: Apply formatter
if: ${{ steps.skip-bot.outputs.skip != 'true' }} if: ${{ steps.skip-bot.outputs.skip != 'true' }}
env: env:
PRETTIER: ${{ steps.presets.outputs.prettier }}
ESLINT: ${{ steps.presets.outputs.eslint }}
RUFF: ${{ steps.presets.outputs.ruff }}
TERRAFORM: ${{ steps.presets.outputs.terraform }}
TERRAFORM_DIR: ${{ inputs.terraform-working-directory }}
FORMAT_COMMAND: ${{ inputs.format-command }} FORMAT_COMMAND: ${{ inputs.format-command }}
LINT_FIX_COMMAND: ${{ inputs.lint-fix-command }} LINT_FIX_COMMAND: ${{ inputs.lint-fix-command }}
EXTRA_COMMAND: ${{ inputs.extra-command }} EXTRA_COMMAND: ${{ inputs.extra-command }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ "${PRETTIER}" = "true" ]; then
npm run format
fi
if [ "${ESLINT}" = "true" ]; then
npx eslint . --fix
fi
if [ "${RUFF}" = "true" ]; then
ruff format .
ruff check --fix .
fi
if [ "${TERRAFORM}" = "true" ]; then
terraform -chdir="${TERRAFORM_DIR}" fmt -recursive
fi
if [ -n "${FORMAT_COMMAND}" ]; then
bash -euo pipefail -c "${FORMAT_COMMAND}" bash -euo pipefail -c "${FORMAT_COMMAND}"
fi
if [ -n "${LINT_FIX_COMMAND}" ]; then if [ -n "${LINT_FIX_COMMAND}" ]; then
bash -euo pipefail -c "${LINT_FIX_COMMAND}" bash -euo pipefail -c "${LINT_FIX_COMMAND}"
fi fi

View file

@ -51,7 +51,7 @@ The formatter GitHub App is not on the main-branch bypass list.
**`.github/workflows/ci-terraform.yaml`** — Terraform `fmt -check`, `init -backend=false`, and `validate`. Default version `1.16.0`. **`.github/workflows/ci-terraform.yaml`** — Terraform `fmt -check`, `init -backend=false`, and `validate`. Default version `1.16.0`.
**`.github/workflows/ci-autofix.yaml`** — Pull-request-only formatter. Mints a GitHub App token (`AUTOFMT_APP_ID`, `AUTOFMT_APP_PRIVATE_KEY`), runs the caller's write commands, and pushes `style: apply formatter` only when the tree is dirty. Output `committed` lets the caller skip portions on SHA_old. Does not `--no-verify` and does not push to `main`. **`.github/workflows/ci-autofix.yaml`** — Pull-request-only formatter. Mints a GitHub App token (`AUTOFMT_APP_ID`, `AUTOFMT_APP_PRIVATE_KEY`), runs the requested presets and any optional write commands, and pushes `style: apply formatter` only when the tree is dirty. Presets are `prettier` (`npm run format`), `eslint` (`npx eslint . --fix`, opt-in), `ruff` (`ruff format .` and `ruff check --fix .`), and `terraform` (`terraform fmt -recursive` in `terraform-working-directory`, default `terraform`). Output `committed` lets the caller skip portions on SHA_old. Does not `--no-verify` and does not push to `main`.
**`.github/workflows/cd-hcp-fargate.yaml`** — HCP Fargate image CD. Checkout at `ref` (empty means `github.sha`), OIDC, SSM cluster/service/family/ecr/container/api-url, docker build+push tagged `$sha` and `$environment`, patch `GIT_SHA`, RegisterTaskDefinition + UpdateService + services-stable, poll health SHA. `environment` is a `with:` input. The reusable job owns `environment:`, concurrency, OIDC, and `vars.DEPLOY_ROLE_ARN`. **`.github/workflows/cd-hcp-fargate.yaml`** — HCP Fargate image CD. Checkout at `ref` (empty means `github.sha`), OIDC, SSM cluster/service/family/ecr/container/api-url, docker build+push tagged `$sha` and `$environment`, patch `GIT_SHA`, RegisterTaskDefinition + UpdateService + services-stable, poll health SHA. `environment` is a `with:` input. The reusable job owns `environment:`, concurrency, OIDC, and `vars.DEPLOY_ROLE_ARN`.
@ -268,8 +268,7 @@ jobs:
permissions: { contents: write } permissions: { contents: write }
secrets: inherit secrets: inherit
with: with:
format-command: npm run format presets: prettier,terraform
lint-fix-command: npm run lint -- --fix
frontend: frontend:
needs: autofix needs: autofix
@ -304,7 +303,7 @@ jobs:
test "${TERRAFORM}" = success test "${TERRAFORM}" = success
``` ```
Python HCP callers pass `format-command: ruff format .` and `lint-fix-command: ruff check --fix .`. Optional `extra-command: terraform fmt -write` is available on `ci-autofix.yaml`. Do not run `eslint --fix` unless that repo's `lint` script is already fix-safe. Flip org ruleset membership in the same window as this merge: include on `CI complete`, exclude from `main branch protection`. Never require both `ci / ci` and `ci-complete`. Do not edit native GitHub merge-queue rulesets. Python HCP callers pass `presets: ruff,terraform`. The `eslint` preset is opt-in and runs `npx eslint . --fix`. Do not pass `npm run lint -- --fix`: several apps chain Redocly into `lint`. Enable `eslint` only when that repo's CI lint step is ESLint itself and Prettier owns formatting. Optional `format-command`, `lint-fix-command`, and `extra-command` still run after the presets. Flip org ruleset membership in the same window as this merge: include on `CI complete`, exclude from `main branch protection`. Never require both `ci / ci` and `ci-complete`. Do not edit native GitHub merge-queue rulesets.
### 3. Add CD to a repo ### 3. Add CD to a repo

View file

@ -17,8 +17,7 @@ jobs:
contents: write contents: write
secrets: inherit secrets: inherit
with: with:
format-command: npm run format presets: prettier,terraform
lint-fix-command: "npm run lint -- --fix"
frontend: frontend:
needs: autofix needs: autofix