From f16b794e8dba824ad4c9013f6a23caf064914575 Mon Sep 17 00:00:00 2001 From: Really Him Date: Tue, 9 Jun 2026 18:55:55 -0400 Subject: [PATCH 1/3] ci: add workflow for prettier write --- .../workflows/format-branch-with-prettier.yml | 632 ++++++++++++++++++ 1 file changed, 632 insertions(+) create mode 100644 .github/workflows/format-branch-with-prettier.yml diff --git a/.github/workflows/format-branch-with-prettier.yml b/.github/workflows/format-branch-with-prettier.yml new file mode 100644 index 0000000..e5989a2 --- /dev/null +++ b/.github/workflows/format-branch-with-prettier.yml @@ -0,0 +1,632 @@ +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 + mode: + description: "How to apply formatting changes." + required: false + default: "push" + type: choice + options: + - push + - pull-request + 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, .nvrmc, .node-version, .npmrc, or package.json engines.node." + required: false + default: "auto" + type: string + formatter: + description: "How to run Prettier." + required: false + default: "auto" + 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 + pr-title: + description: "Pull request title when mode is pull-request." + required: false + default: "style: format with prettier" + type: string + pr-branch-prefix: + description: "Branch prefix when mode is pull-request." + required: false + default: "automation/prettier-format" + type: string + + workflow_call: + inputs: + ref: + required: false + default: "" + type: string + path: + required: false + default: "." + type: string + mode: + required: false + default: "push" + type: string + package-manager: + required: false + default: "auto" + type: string + node-version: + required: false + default: "auto" + type: string + formatter: + required: false + default: "auto" + type: string + script-name: + required: false + default: "" + type: string + commit-message: + required: false + default: "style: format with prettier" + type: string + pr-title: + required: false + default: "style: format with prettier" + type: string + pr-branch-prefix: + required: false + default: "automation/prettier-format" + type: string + +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-latest + + permissions: + contents: read + + outputs: + changed: ${{ steps.diff.outputs.changed }} + mode: ${{ steps.resolve.outputs.mode }} + target_branch: ${{ steps.resolve.outputs.target_branch }} + + steps: + - name: Resolve inputs + id: resolve + shell: bash + env: + INPUT_REF: ${{ inputs.ref }} + INPUT_MODE: ${{ inputs.mode }} + GITHUB_REF_NAME_SAFE: ${{ github.ref_name }} + run: | + set -euo pipefail + + mode="$INPUT_MODE" + case "$mode" in + push|pull-request) ;; + *) + echo "Invalid mode: $mode" >&2 + exit 1 + ;; + esac + + 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 "mode=$mode" + echo "target_branch=$target_branch" + } >> "$GITHUB_OUTPUT" + + - name: Checkout target branch + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ steps.resolve.outputs.target_branch }} + fetch-depth: 0 + persist-credentials: false + + - name: Detect Node.js version + id: node + shell: bash + env: + INPUT_NODE_VERSION: ${{ inputs.node-version }} + run: | + set -euo pipefail + + node_version="$INPUT_NODE_VERSION" + + if [ "$node_version" = "auto" ] || [ -z "$node_version" ]; then + node_version="" + + for version_file in .nvmrc .nvrmc .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="$( + python3 - <<'PY' + import json + import re + from pathlib import Path + + try: + package = json.loads(Path("package.json").read_text()) + except Exception: + print("") + raise SystemExit(0) + + spec = str(package.get("engines", {}).get("node", "")).strip() + if not spec: + print("") + raise SystemExit(0) + + # Prefer a concrete major version when package.json uses a range such as >=24. + match = re.search(r'(?> "$GITHUB_OUTPUT" + + - name: Detect package manager + id: pm + shell: bash + env: + INPUT_PACKAGE_MANAGER: ${{ inputs.package-manager }} + run: | + set -euo pipefail + + manager="$INPUT_PACKAGE_MANAGER" + + if [ "$manager" = "auto" ] || [ -z "$manager" ]; then + manager="$( + python3 - <<'PY' + import json + from pathlib import Path + + try: + package = json.loads(Path("package.json").read_text()) + except Exception: + package = {} + + candidates = [ + str(package.get("packageManager", "")), + str(package.get("devEngines", {}).get("packageManager", {}).get("name", "")), + str(package.get("devEngines", {}).get("packageManager", "")), + ] + + for candidate in candidates: + if candidate.startswith("pnpm@") or candidate == "pnpm": + print("pnpm") + raise SystemExit(0) + if candidate.startswith("yarn@") or candidate == "yarn": + print("yarn") + raise SystemExit(0) + if candidate.startswith("npm@") or candidate == "npm": + print("npm") + raise SystemExit(0) + + if Path("pnpm-lock.yaml").exists(): + print("pnpm") + elif Path("yarn.lock").exists(): + print("yarn") + else: + print("npm") + PY + )" + 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: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v.6.4.0 + with: + node-version: ${{ steps.node.outputs.node_version }} + package-manager-cache: false + + - name: Enable Corepack + if: ${{ steps.pm.outputs.manager == 'pnpm' || steps.pm.outputs.manager == 'yarn' }} + run: corepack enable + + - name: Install dependencies + shell: bash + env: + PACKAGE_MANAGER: ${{ steps.pm.outputs.manager }} + HUSKY: "0" + run: | + set -euo pipefail + + 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: Select Prettier command + id: prettier + shell: bash + env: + INPUT_FORMATTER: ${{ inputs.formatter }} + INPUT_SCRIPT_NAME: ${{ inputs.script-name }} + INPUT_PATH: ${{ inputs.path }} + run: | + set -euo pipefail + + 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 || {}; + + function hasPrettierWrite(command) { + return /\bprettier\b/.test(command) && /(^|\s)--write(\s|$)/.test(command); + } + + function isFormattingOnly(command) { + if (!hasPrettierWrite(command)) return false; + + // Treat shell composition as not formatting-only. Extraction from arbitrary + // npm scripts is intentionally avoided because shell parsing is too easy to + // get wrong in a security-sensitive workflow. + 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}`); + 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]}`); + process.exit(0); + } + + if (formatter === 'auto' && targetPath === '.' && candidates.length > 0) { + console.log('kind=script'); + console.log(`script_name=${candidates[0][0]}`); + process.exit(0); + } + + console.log('kind=direct'); + console.log('script_name='); + NODE + + - name: Run Prettier + shell: bash + env: + PACKAGE_MANAGER: ${{ steps.pm.outputs.manager }} + PRETTIER_KIND: ${{ steps.prettier.outputs.kind }} + SCRIPT_NAME: ${{ steps.prettier.outputs.script_name }} + TARGET_PATH: ${{ inputs.path }} + run: | + set -euo pipefail + + 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 + + case "$PACKAGE_MANAGER" in + npm) + npm exec -- prettier --write --ignore-unknown -- "$TARGET_PATH" + ;; + pnpm) + pnpm exec prettier --write --ignore-unknown -- "$TARGET_PATH" + ;; + yarn) + yarn exec prettier --write --ignore-unknown -- "$TARGET_PATH" + ;; + esac + + - name: Create patch + id: diff + shell: bash + run: | + set -euo pipefail + + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No Prettier changes." + exit 0 + fi + + git status --short + git diff --binary > "$RUNNER_TEMP/prettier.patch" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Upload patch + if: ${{ steps.diff.outputs.changed == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: prettier-patch + path: ${{ runner.temp }}/prettier.patch + if-no-files-found: error + retention-days: 1 + + push: + name: Push formatting commit + needs: format + if: ${{ needs.format.outputs.changed == 'true' && needs.format.outputs.mode == 'push' }} + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - name: Checkout target branch + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.format.outputs.target_branch }} + fetch-depth: 0 + persist-credentials: true + + - name: Download patch + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: prettier-patch + path: ${{ runner.temp }} + + - name: Commit and push + 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" + git push origin "HEAD:refs/heads/$TARGET_BRANCH" + + pull-request: + name: Open formatting pull request + needs: format + if: ${{ needs.format.outputs.changed == 'true' && needs.format.outputs.mode == 'pull-request' }} + runs-on: ubuntu-latest + + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout target branch + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.format.outputs.target_branch }} + fetch-depth: 0 + persist-credentials: true + + - name: Download patch + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: prettier-patch + path: ${{ runner.temp }} + + - name: Commit, push branch, and open PR + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TARGET_BRANCH: ${{ needs.format.outputs.target_branch }} + COMMIT_MESSAGE: ${{ inputs.commit-message }} + PR_TITLE: ${{ inputs.pr-title }} + PR_BRANCH_PREFIX: ${{ inputs.pr-branch-prefix }} + 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 + + safe_base="$( + printf '%s' "$TARGET_BRANCH" | + tr -c 'A-Za-z0-9._-' '-' | + sed -E 's/-+/-/g; s/^-//; s/-$//' | + cut -c 1-60 + )" + + safe_prefix="$( + printf '%s' "$PR_BRANCH_PREFIX" | + tr -c 'A-Za-z0-9._/-' '-' | + sed -E 's/-+/-/g; s#/{2,}#/#g; s#^/##; s#/$##' + )" + + if [ -z "$safe_prefix" ]; then + safe_prefix="automation/prettier-format" + fi + + pr_branch="$safe_prefix/$safe_base-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + + git switch -c "$pr_branch" + 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" + git push origin "HEAD:refs/heads/$pr_branch" + + gh pr create \ + --base "$TARGET_BRANCH" \ + --head "$pr_branch" \ + --title "$PR_TITLE" \ + --body "Formats the target branch with the repository's Prettier configuration." From 30899f1ba5e7f091485d469894210ee12a41b298 Mon Sep 17 00:00:00 2001 From: Really Him Date: Tue, 9 Jun 2026 19:29:03 -0400 Subject: [PATCH 2/3] refactor: extract prettier logic into action file --- .github/actions/prettier-format/action.yml | 369 +++++++++++++ .../workflows/format-branch-with-prettier.yml | 503 +++--------------- 2 files changed, 442 insertions(+), 430 deletions(-) create mode 100644 .github/actions/prettier-format/action.yml 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 index e5989a2..5e05608 100644 --- a/.github/workflows/format-branch-with-prettier.yml +++ b/.github/workflows/format-branch-with-prettier.yml @@ -13,14 +13,6 @@ on: required: false default: "." type: string - mode: - description: "How to apply formatting changes." - required: false - default: "push" - type: choice - options: - - push - - pull-request package-manager: description: "Package manager." required: false @@ -32,14 +24,14 @@ on: - pnpm - yarn node-version: - description: "Node.js version. Use auto to read .nvmrc, .nvrmc, .node-version, .npmrc, or package.json engines.node." + 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: "auto" + default: "prettier" type: choice options: - auto @@ -55,16 +47,14 @@ on: required: false default: "style: format with prettier" type: string - pr-title: - description: "Pull request title when mode is pull-request." + push-token: + description: "Token used to push the formatting commit." required: false - default: "style: format with prettier" - type: string - pr-branch-prefix: - description: "Branch prefix when mode is pull-request." - required: false - default: "automation/prettier-format" - type: string + default: "github-app" + type: choice + options: + - github-token + - github-app workflow_call: inputs: @@ -76,10 +66,6 @@ on: required: false default: "." type: string - mode: - required: false - default: "push" - type: string package-manager: required: false default: "auto" @@ -90,7 +76,7 @@ on: type: string formatter: required: false - default: "auto" + default: "prettier" type: string script-name: required: false @@ -100,14 +86,15 @@ on: required: false default: "style: format with prettier" type: string - pr-title: + push-token: required: false - default: "style: format with prettier" + default: "github-app" type: string - pr-branch-prefix: + secrets: + PRETTIER_FORMATTER_APP_CLIENT_ID: + required: false + PRETTIER_FORMATTER_APP_PRIVATE_KEY: required: false - default: "automation/prettier-format" - type: string permissions: contents: read @@ -119,14 +106,14 @@ concurrency: jobs: format: name: Format branch - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + timeout-minutes: 10 permissions: contents: read outputs: - changed: ${{ steps.diff.outputs.changed }} - mode: ${{ steps.resolve.outputs.mode }} + changed: ${{ steps.prettier.outputs.changed }} target_branch: ${{ steps.resolve.outputs.target_branch }} steps: @@ -135,20 +122,10 @@ jobs: shell: bash env: INPUT_REF: ${{ inputs.ref }} - INPUT_MODE: ${{ inputs.mode }} GITHUB_REF_NAME_SAFE: ${{ github.ref_name }} run: | set -euo pipefail - mode="$INPUT_MODE" - case "$mode" in - push|pull-request) ;; - *) - echo "Invalid mode: $mode" >&2 - exit 1 - ;; - esac - target_branch="$INPUT_REF" if [ -z "$target_branch" ]; then target_branch="$GITHUB_REF_NAME_SAFE" @@ -175,404 +152,84 @@ jobs: fi { - echo "mode=$mode" 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: Detect Node.js version - id: node - shell: bash - env: - INPUT_NODE_VERSION: ${{ inputs.node-version }} - run: | - set -euo pipefail - - node_version="$INPUT_NODE_VERSION" - - if [ "$node_version" = "auto" ] || [ -z "$node_version" ]; then - node_version="" - - for version_file in .nvmrc .nvrmc .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="$( - python3 - <<'PY' - import json - import re - from pathlib import Path - - try: - package = json.loads(Path("package.json").read_text()) - except Exception: - print("") - raise SystemExit(0) - - spec = str(package.get("engines", {}).get("node", "")).strip() - if not spec: - print("") - raise SystemExit(0) - - # Prefer a concrete major version when package.json uses a range such as >=24. - match = re.search(r'(?> "$GITHUB_OUTPUT" - - - name: Detect package manager - id: pm - shell: bash - env: - INPUT_PACKAGE_MANAGER: ${{ inputs.package-manager }} - run: | - set -euo pipefail - - manager="$INPUT_PACKAGE_MANAGER" - - if [ "$manager" = "auto" ] || [ -z "$manager" ]; then - manager="$( - python3 - <<'PY' - import json - from pathlib import Path - - try: - package = json.loads(Path("package.json").read_text()) - except Exception: - package = {} - - candidates = [ - str(package.get("packageManager", "")), - str(package.get("devEngines", {}).get("packageManager", {}).get("name", "")), - str(package.get("devEngines", {}).get("packageManager", "")), - ] - - for candidate in candidates: - if candidate.startswith("pnpm@") or candidate == "pnpm": - print("pnpm") - raise SystemExit(0) - if candidate.startswith("yarn@") or candidate == "yarn": - print("yarn") - raise SystemExit(0) - if candidate.startswith("npm@") or candidate == "npm": - print("npm") - raise SystemExit(0) - - if Path("pnpm-lock.yaml").exists(): - print("pnpm") - elif Path("yarn.lock").exists(): - print("yarn") - else: - print("npm") - PY - )" - 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: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v.6.4.0 - with: - node-version: ${{ steps.node.outputs.node_version }} - package-manager-cache: false - - - name: Enable Corepack - if: ${{ steps.pm.outputs.manager == 'pnpm' || steps.pm.outputs.manager == 'yarn' }} - run: corepack enable - - - name: Install dependencies - shell: bash - env: - PACKAGE_MANAGER: ${{ steps.pm.outputs.manager }} - HUSKY: "0" - run: | - set -euo pipefail - - 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: Select Prettier command + - name: Format with Prettier id: prettier - shell: bash - env: - INPUT_FORMATTER: ${{ inputs.formatter }} - INPUT_SCRIPT_NAME: ${{ inputs.script-name }} - INPUT_PATH: ${{ inputs.path }} - run: | - set -euo pipefail - - 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 || {}; - - function hasPrettierWrite(command) { - return /\bprettier\b/.test(command) && /(^|\s)--write(\s|$)/.test(command); - } - - function isFormattingOnly(command) { - if (!hasPrettierWrite(command)) return false; - - // Treat shell composition as not formatting-only. Extraction from arbitrary - // npm scripts is intentionally avoided because shell parsing is too easy to - // get wrong in a security-sensitive workflow. - 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}`); - 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]}`); - process.exit(0); - } - - if (formatter === 'auto' && targetPath === '.' && candidates.length > 0) { - console.log('kind=script'); - console.log(`script_name=${candidates[0][0]}`); - process.exit(0); - } - - console.log('kind=direct'); - console.log('script_name='); - NODE - - - name: Run Prettier - shell: bash - env: - PACKAGE_MANAGER: ${{ steps.pm.outputs.manager }} - PRETTIER_KIND: ${{ steps.prettier.outputs.kind }} - SCRIPT_NAME: ${{ steps.prettier.outputs.script_name }} - TARGET_PATH: ${{ inputs.path }} - run: | - set -euo pipefail - - 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 - - case "$PACKAGE_MANAGER" in - npm) - npm exec -- prettier --write --ignore-unknown -- "$TARGET_PATH" - ;; - pnpm) - pnpm exec prettier --write --ignore-unknown -- "$TARGET_PATH" - ;; - yarn) - yarn exec prettier --write --ignore-unknown -- "$TARGET_PATH" - ;; - esac - - - name: Create patch - id: diff - shell: bash - run: | - set -euo pipefail - - if git diff --quiet; then - echo "changed=false" >> "$GITHUB_OUTPUT" - echo "No Prettier changes." - exit 0 - fi - - git status --short - git diff --binary > "$RUNNER_TEMP/prettier.patch" - echo "changed=true" >> "$GITHUB_OUTPUT" + 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.diff.outputs.changed == 'true' }} + if: ${{ steps.prettier.outputs.changed == 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: prettier-patch - path: ${{ runner.temp }}/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' && needs.format.outputs.mode == 'push' }} - runs-on: ubuntu-latest + if: ${{ needs.format.outputs.changed == 'true' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 permissions: contents: write steps: - - name: Checkout target branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - ref: ${{ needs.format.outputs.target_branch }} - fetch-depth: 0 - persist-credentials: true - - - name: Download patch - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: prettier-patch - path: ${{ runner.temp }} - - - name: Commit and push + - name: Validate push token input shell: bash env: - TARGET_BRANCH: ${{ needs.format.outputs.target_branch }} - COMMIT_MESSAGE: ${{ inputs.commit-message }} + PUSH_TOKEN: ${{ inputs.push-token }} 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" - git push origin "HEAD:refs/heads/$TARGET_BRANCH" - - pull-request: - name: Open formatting pull request - needs: format - if: ${{ needs.format.outputs.changed == 'true' && needs.format.outputs.mode == 'pull-request' }} - runs-on: ubuntu-latest + case "$PUSH_TOKEN" in + github-token|github-app) ;; + *) + echo "Invalid push-token: $PUSH_TOKEN" >&2 + exit 1 + ;; + esac - permissions: - contents: write - pull-requests: write + - name: Create GitHub App token + id: app-token + if: ${{ inputs.push-token == 'github-app' }} + 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 - steps: - name: Checkout target branch uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.format.outputs.target_branch }} fetch-depth: 0 persist-credentials: true + token: ${{ steps.app-token.outputs.token || github.token }} - name: Download patch uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -580,14 +237,12 @@ jobs: name: prettier-patch path: ${{ runner.temp }} - - name: Commit, push branch, and open PR + - name: Commit and push shell: bash env: - GH_TOKEN: ${{ github.token }} TARGET_BRANCH: ${{ needs.format.outputs.target_branch }} COMMIT_MESSAGE: ${{ inputs.commit-message }} - PR_TITLE: ${{ inputs.pr-title }} - PR_BRANCH_PREFIX: ${{ inputs.pr-branch-prefix }} + PUSH_TOKEN: ${{ inputs.push-token }} run: | set -euo pipefail @@ -598,35 +253,23 @@ jobs: exit 0 fi - safe_base="$( - printf '%s' "$TARGET_BRANCH" | - tr -c 'A-Za-z0-9._-' '-' | - sed -E 's/-+/-/g; s/^-//; s/-$//' | - cut -c 1-60 - )" - - safe_prefix="$( - printf '%s' "$PR_BRANCH_PREFIX" | - tr -c 'A-Za-z0-9._/-' '-' | - sed -E 's/-+/-/g; s#/{2,}#/#g; s#^/##; s#/$##' - )" - - if [ -z "$safe_prefix" ]; then - safe_prefix="automation/prettier-format" - fi - - pr_branch="$safe_prefix/$safe_base-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - - git switch -c "$pr_branch" 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" - git push origin "HEAD:refs/heads/$pr_branch" + git push origin "HEAD:refs/heads/$TARGET_BRANCH" - gh pr create \ - --base "$TARGET_BRANCH" \ - --head "$pr_branch" \ - --title "$PR_TITLE" \ - --body "Formats the target branch with the repository's Prettier configuration." + { + echo "### Formatting commit pushed" + echo + echo "- Branch: \`$TARGET_BRANCH\`" + echo "- Token source: \`$PUSH_TOKEN\`" + if [ "$PUSH_TOKEN" = "github-token" ]; then + echo + echo "This push used \`GITHUB_TOKEN\`; GitHub generally does not create new workflow runs from that push." + else + echo + echo "This push used a GitHub App installation token; normal push and pull request checks should be eligible to run." + fi + } >> "$GITHUB_STEP_SUMMARY" From 6913286126e019d0482610e2959f56e1812384dd Mon Sep 17 00:00:00 2001 From: Really Him Date: Tue, 9 Jun 2026 19:55:42 -0400 Subject: [PATCH 3/3] refactor: least privilege --- .../workflows/format-branch-with-prettier.yml | 81 +++++++------------ 1 file changed, 29 insertions(+), 52 deletions(-) diff --git a/.github/workflows/format-branch-with-prettier.yml b/.github/workflows/format-branch-with-prettier.yml index 5e05608..2fbf697 100644 --- a/.github/workflows/format-branch-with-prettier.yml +++ b/.github/workflows/format-branch-with-prettier.yml @@ -47,15 +47,6 @@ on: required: false default: "style: format with prettier" type: string - push-token: - description: "Token used to push the formatting commit." - required: false - default: "github-app" - type: choice - options: - - github-token - - github-app - workflow_call: inputs: ref: @@ -86,15 +77,11 @@ on: required: false default: "style: format with prettier" type: string - push-token: - required: false - default: "github-app" - type: string secrets: PRETTIER_FORMATTER_APP_CLIENT_ID: - required: false + required: true PRETTIER_FORMATTER_APP_PRIVATE_KEY: - required: false + required: true permissions: contents: read @@ -196,40 +183,15 @@ jobs: timeout-minutes: 10 permissions: - contents: write + contents: read steps: - - name: Validate push token input - shell: bash - env: - PUSH_TOKEN: ${{ inputs.push-token }} - run: | - set -euo pipefail - - case "$PUSH_TOKEN" in - github-token|github-app) ;; - *) - echo "Invalid push-token: $PUSH_TOKEN" >&2 - exit 1 - ;; - esac - - - name: Create GitHub App token - id: app-token - if: ${{ inputs.push-token == 'github-app' }} - 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: Checkout target branch uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.format.outputs.target_branch }} fetch-depth: 0 - persist-credentials: true - token: ${{ steps.app-token.outputs.token || github.token }} + persist-credentials: false - name: Download patch uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -237,12 +199,12 @@ jobs: name: prettier-patch path: ${{ runner.temp }} - - name: Commit and push + - name: Commit formatting changes + id: commit shell: bash env: TARGET_BRANCH: ${{ needs.format.outputs.target_branch }} COMMIT_MESSAGE: ${{ inputs.commit-message }} - PUSH_TOKEN: ${{ inputs.push-token }} run: | set -euo pipefail @@ -258,18 +220,33 @@ jobs: 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 "- Token source: \`$PUSH_TOKEN\`" - if [ "$PUSH_TOKEN" = "github-token" ]; then - echo - echo "This push used \`GITHUB_TOKEN\`; GitHub generally does not create new workflow runs from that push." - else - echo - echo "This push used a GitHub App installation token; normal push and pull request checks should be eligible to run." - fi + 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"