diff --git a/.github/actions/prettier-format/action.yml b/.github/actions/prettier-format/action.yml new file mode 100644 index 0000000..fe8df8c --- /dev/null +++ b/.github/actions/prettier-format/action.yml @@ -0,0 +1,369 @@ +name: Format with Prettier +description: Detects project tooling, runs Prettier, and emits a patch when files change. + +inputs: + path: + description: File, directory, or quoted glob to format. + required: false + default: . + package-manager: + description: Package manager to use. + required: false + default: auto + node-version: + description: Node.js version. Use auto to read project metadata. + required: false + default: auto + formatter: + description: How to run Prettier. + required: false + default: prettier + script-name: + description: Optional package.json script to run when formatter is package-script. + required: false + default: "" + working-directory: + description: Directory containing the repository to format. + required: false + default: . + +outputs: + changed: + description: Whether Prettier changed the working tree. + value: ${{ steps.diff.outputs.changed }} + patch_path: + description: Path to the generated patch when changed is true. + value: ${{ steps.diff.outputs.patch_path }} + +runs: + using: composite + steps: + - name: Detect Node.js version + id: node + shell: bash + env: + INPUT_NODE_VERSION: ${{ inputs.node-version }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + run: | + set -euo pipefail + cd "$WORKING_DIRECTORY" + + node_version="$INPUT_NODE_VERSION" + + if [ "$node_version" = "auto" ] || [ -z "$node_version" ]; then + node_version="" + + for version_file in .nvmrc .node-version; do + if [ -f "$version_file" ]; then + node_version="$(grep -v '^[[:space:]]*$' "$version_file" | grep -v '^[[:space:]]*#' | head -n 1 | tr -d '[:space:]')" + if [ -n "$node_version" ]; then + echo "Detected Node.js version from $version_file: $node_version" + break + fi + fi + done + + if [ -z "$node_version" ] && [ -f .npmrc ]; then + node_version="$( + awk -F= ' + /^[[:space:]]*(node-version|node_version|use-node-version)[[:space:]]*=/ { + value=$2 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + print value + exit + } + ' .npmrc + )" + if [ -n "$node_version" ]; then + echo "Detected Node.js version from .npmrc: $node_version" + fi + fi + + if [ -z "$node_version" ] && [ -f package.json ]; then + node_version="$( + node <<'NODE' + const fs = require('node:fs'); + + try { + const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const spec = String(packageJson.engines?.node || '').trim(); + const match = spec.match(/(?> "$GITHUB_OUTPUT" + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ steps.node.outputs.node_version }} + package-manager-cache: false + + - name: Select Prettier command + id: prettier + shell: bash + env: + INPUT_FORMATTER: ${{ inputs.formatter }} + INPUT_SCRIPT_NAME: ${{ inputs.script-name }} + INPUT_PATH: ${{ inputs.path }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + run: | + set -euo pipefail + cd "$WORKING_DIRECTORY" + + node <<'NODE' >> "$GITHUB_OUTPUT" + const fs = require('node:fs'); + + const formatter = process.env.INPUT_FORMATTER || 'auto'; + const requestedScript = process.env.INPUT_SCRIPT_NAME || ''; + const targetPath = process.env.INPUT_PATH || '.'; + + if (!['auto', 'prettier', 'package-script'].includes(formatter)) { + console.error(`Invalid formatter: ${formatter}`); + process.exit(1); + } + + let packageJson = {}; + try { + packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); + } catch {} + + const scripts = packageJson.scripts || {}; + const prettierSpec = + packageJson.devDependencies?.prettier || + packageJson.dependencies?.prettier || + packageJson.optionalDependencies?.prettier || + ''; + const prettierPackage = prettierSpec ? `prettier@${prettierSpec}` : 'prettier'; + + function hasPrettierWrite(command) { + return /\bprettier\b/.test(command) && /(^|\s)--write(\s|$)/.test(command); + } + + function isFormattingOnly(command) { + if (!hasPrettierWrite(command)) return false; + + if (/[;&|`<>]/.test(command)) return false; + + const withoutPrettier = command.replace(/\bprettier\b/g, '').toLowerCase(); + + return !/\b(eslint|lint-staged|tsc|vitest|jest|mocha|ava|node|npm|pnpm|yarn|bash|sh|biome|rome)\b/.test(withoutPrettier); + } + + const preferredNames = [ + 'format', + 'format:fix', + 'prettier', + 'prettier:write', + 'prettier:fix', + 'fix:format', + 'lint:format' + ]; + + const candidates = Object.entries(scripts) + .filter(([, command]) => isFormattingOnly(String(command))) + .sort(([a], [b]) => { + const ai = preferredNames.indexOf(a); + const bi = preferredNames.indexOf(b); + return (ai === -1 ? Number.MAX_SAFE_INTEGER : ai) - (bi === -1 ? Number.MAX_SAFE_INTEGER : bi) || a.localeCompare(b); + }); + + if (requestedScript) { + const command = scripts[requestedScript]; + if (!command) { + console.error(`package.json script not found: ${requestedScript}`); + process.exit(1); + } + if (!isFormattingOnly(String(command))) { + console.error(`Refusing to run script "${requestedScript}" because it is not a single-purpose prettier --write script.`); + process.exit(1); + } + + console.log('kind=script'); + console.log(`script_name=${requestedScript}`); + console.log(`prettier_package=${prettierPackage}`); + process.exit(0); + } + + if (formatter === 'package-script') { + if (candidates.length === 0) { + console.error('No single-purpose prettier --write package.json script was found.'); + process.exit(1); + } + + console.log('kind=script'); + console.log(`script_name=${candidates[0][0]}`); + console.log(`prettier_package=${prettierPackage}`); + process.exit(0); + } + + if (formatter === 'auto' && targetPath === '.' && candidates.length > 0) { + console.log('kind=script'); + console.log(`script_name=${candidates[0][0]}`); + console.log(`prettier_package=${prettierPackage}`); + process.exit(0); + } + + console.log('kind=direct'); + console.log('script_name='); + console.log(`prettier_package=${prettierPackage}`); + NODE + + - name: Detect package manager + id: pm + if: ${{ steps.prettier.outputs.kind == 'script' }} + shell: bash + env: + INPUT_PACKAGE_MANAGER: ${{ inputs.package-manager }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + run: | + set -euo pipefail + cd "$WORKING_DIRECTORY" + + manager="$INPUT_PACKAGE_MANAGER" + + if [ "$manager" = "auto" ] || [ -z "$manager" ]; then + manager="$( + node <<'NODE' + const fs = require('node:fs'); + + let packageJson = {}; + try { + packageJson = JSON.parse(fs.readFileSync('package.json', 'utf8')); + } catch {} + + const candidates = [ + String(packageJson.packageManager || ''), + String(packageJson.devEngines?.packageManager?.name || ''), + String(packageJson.devEngines?.packageManager || '') + ]; + + for (const candidate of candidates) { + if (candidate.startsWith('pnpm@') || candidate === 'pnpm') { + console.log('pnpm'); + process.exit(0); + } + if (candidate.startsWith('yarn@') || candidate === 'yarn') { + console.log('yarn'); + process.exit(0); + } + if (candidate.startsWith('npm@') || candidate === 'npm') { + console.log('npm'); + process.exit(0); + } + } + + if (fs.existsSync('pnpm-lock.yaml')) { + console.log('pnpm'); + } else if (fs.existsSync('yarn.lock')) { + console.log('yarn'); + } else { + console.log('npm'); + } + NODE + )" + fi + + case "$manager" in + npm|pnpm|yarn) ;; + *) + echo "Unsupported package manager: $manager" >&2 + exit 1 + ;; + esac + + echo "manager=$manager" >> "$GITHUB_OUTPUT" + echo "Detected package manager: $manager" + + - name: Enable Corepack + if: ${{ steps.prettier.outputs.kind == 'script' && (steps.pm.outputs.manager == 'pnpm' || steps.pm.outputs.manager == 'yarn') }} + shell: bash + run: corepack enable + + - name: Install dependencies for package script + if: ${{ steps.prettier.outputs.kind == 'script' }} + shell: bash + env: + PACKAGE_MANAGER: ${{ steps.pm.outputs.manager }} + HUSKY: "0" + WORKING_DIRECTORY: ${{ inputs.working-directory }} + run: | + set -euo pipefail + cd "$WORKING_DIRECTORY" + + case "$PACKAGE_MANAGER" in + npm) + if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then + npm ci + else + npm install + fi + ;; + pnpm) + pnpm install --frozen-lockfile + ;; + yarn) + yarn install --immutable || yarn install --frozen-lockfile + ;; + esac + + - name: Run Prettier + shell: bash + env: + PACKAGE_MANAGER: ${{ steps.pm.outputs.manager }} + PRETTIER_PACKAGE: ${{ steps.prettier.outputs.prettier_package }} + PRETTIER_KIND: ${{ steps.prettier.outputs.kind }} + SCRIPT_NAME: ${{ steps.prettier.outputs.script_name }} + TARGET_PATH: ${{ inputs.path }} + WORKING_DIRECTORY: ${{ inputs.working-directory }} + run: | + set -euo pipefail + cd "$WORKING_DIRECTORY" + + if [ "$PRETTIER_KIND" = "script" ]; then + case "$PACKAGE_MANAGER" in + npm) npm run "$SCRIPT_NAME" ;; + pnpm) pnpm run "$SCRIPT_NAME" ;; + yarn) yarn run "$SCRIPT_NAME" ;; + esac + exit 0 + fi + + npm exec --yes --package "$PRETTIER_PACKAGE" -- prettier --write --ignore-unknown -- "$TARGET_PATH" + + - name: Create patch + id: diff + shell: bash + env: + WORKING_DIRECTORY: ${{ inputs.working-directory }} + run: | + set -euo pipefail + cd "$WORKING_DIRECTORY" + + patch_path="$RUNNER_TEMP/prettier.patch" + + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "patch_path=$patch_path" >> "$GITHUB_OUTPUT" + echo "No Prettier changes." + exit 0 + fi + + git status --short + git diff --binary > "$patch_path" + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "patch_path=$patch_path" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/format-branch-with-prettier.yml b/.github/workflows/format-branch-with-prettier.yml new file mode 100644 index 0000000..2fbf697 --- /dev/null +++ b/.github/workflows/format-branch-with-prettier.yml @@ -0,0 +1,252 @@ +name: Format branch with Prettier + +on: + workflow_dispatch: + inputs: + ref: + description: "Branch to format. Leave blank to use the branch selected in the Run workflow UI." + required: false + default: "" + type: string + path: + description: "File, directory, or quoted glob to format." + required: false + default: "." + type: string + package-manager: + description: "Package manager." + required: false + default: "auto" + type: choice + options: + - auto + - npm + - pnpm + - yarn + node-version: + description: "Node.js version. Use auto to read .nvmrc, .node-version, .npmrc, or package.json engines.node." + required: false + default: "auto" + type: string + formatter: + description: "How to run Prettier." + required: false + default: "prettier" + type: choice + options: + - auto + - prettier + - package-script + script-name: + description: "Optional package.json script to run when formatter is package-script." + required: false + default: "" + type: string + commit-message: + description: "Commit message for formatting changes." + required: false + default: "style: format with prettier" + type: string + workflow_call: + inputs: + ref: + required: false + default: "" + type: string + path: + required: false + default: "." + type: string + package-manager: + required: false + default: "auto" + type: string + node-version: + required: false + default: "auto" + type: string + formatter: + required: false + default: "prettier" + type: string + script-name: + required: false + default: "" + type: string + commit-message: + required: false + default: "style: format with prettier" + type: string + secrets: + PRETTIER_FORMATTER_APP_CLIENT_ID: + required: true + PRETTIER_FORMATTER_APP_PRIVATE_KEY: + required: true + +permissions: + contents: read + +concurrency: + group: prettier-format-${{ github.repository }}-${{ inputs.ref || github.ref_name }} + cancel-in-progress: false + +jobs: + format: + name: Format branch + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + permissions: + contents: read + + outputs: + changed: ${{ steps.prettier.outputs.changed }} + target_branch: ${{ steps.resolve.outputs.target_branch }} + + steps: + - name: Resolve inputs + id: resolve + shell: bash + env: + INPUT_REF: ${{ inputs.ref }} + GITHUB_REF_NAME_SAFE: ${{ github.ref_name }} + run: | + set -euo pipefail + + target_branch="$INPUT_REF" + if [ -z "$target_branch" ]; then + target_branch="$GITHUB_REF_NAME_SAFE" + fi + + if [ -z "$target_branch" ]; then + echo "Unable to determine target branch." >&2 + exit 1 + fi + + if [ "${target_branch#-}" != "$target_branch" ]; then + echo "Target branch cannot start with '-'." >&2 + exit 1 + fi + + if [ "${target_branch#refs/}" != "$target_branch" ]; then + echo "Use an unqualified branch name, not refs/*: $target_branch" >&2 + exit 1 + fi + + if ! git check-ref-format --branch "$target_branch" >/dev/null; then + echo "Invalid branch name: $target_branch" >&2 + exit 1 + fi + + { + echo "target_branch=$target_branch" + } >> "$GITHUB_OUTPUT" + + - name: Checkout workflow repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Checkout target branch + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ steps.resolve.outputs.target_branch }} + fetch-depth: 0 + persist-credentials: false + path: target-branch + + - name: Format with Prettier + id: prettier + uses: ./.github/actions/prettier-format + with: + working-directory: target-branch + path: ${{ inputs.path }} + package-manager: ${{ inputs.package-manager }} + node-version: ${{ inputs.node-version }} + formatter: ${{ inputs.formatter }} + script-name: ${{ inputs.script-name }} + + - name: Upload patch + if: ${{ steps.prettier.outputs.changed == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: prettier-patch + path: ${{ steps.prettier.outputs.patch_path }} + if-no-files-found: error + retention-days: 1 + + push: + name: Push formatting commit + needs: format + if: ${{ needs.format.outputs.changed == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + + permissions: + contents: read + + steps: + - name: Checkout target branch + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.format.outputs.target_branch }} + fetch-depth: 0 + persist-credentials: false + + - name: Download patch + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: prettier-patch + path: ${{ runner.temp }} + + - name: Commit formatting changes + id: commit + shell: bash + env: + TARGET_BRANCH: ${{ needs.format.outputs.target_branch }} + COMMIT_MESSAGE: ${{ inputs.commit-message }} + run: | + set -euo pipefail + + git apply --binary "$RUNNER_TEMP/prettier.patch" + + if git diff --quiet; then + echo "Patch applied cleanly but produced no working tree changes." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git add -A + git commit -m "$COMMIT_MESSAGE" + + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Create GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.PRETTIER_FORMATTER_APP_CLIENT_ID }} + private-key: ${{ secrets.PRETTIER_FORMATTER_APP_PRIVATE_KEY }} + permission-contents: write + + - name: Push formatting commit + shell: bash + env: + TARGET_BRANCH: ${{ needs.format.outputs.target_branch }} + PUSH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + + git remote set-url origin "https://x-access-token:${PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + git push origin "HEAD:refs/heads/$TARGET_BRANCH" + + { + echo "### Formatting commit pushed" + echo + echo "- Branch: \`$TARGET_BRANCH\`" + echo "- Commit: \`${{ steps.commit.outputs.sha }}\`" + echo + echo "This push used a GitHub App installation token; normal push and pull request checks should be eligible to run." + } >> "$GITHUB_STEP_SUMMARY"