From 8e450240452ec9feffecbaf3ac5e22b2b1548703 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 27 Jun 2026 08:00:06 -0700 Subject: [PATCH 1/4] feat: cover pre-commit autoupdate preflight --- tests/pre-commit-autoupdate-preflight.bats | 136 +++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 tests/pre-commit-autoupdate-preflight.bats diff --git a/tests/pre-commit-autoupdate-preflight.bats b/tests/pre-commit-autoupdate-preflight.bats new file mode 100644 index 0000000..6c2ce43 --- /dev/null +++ b/tests/pre-commit-autoupdate-preflight.bats @@ -0,0 +1,136 @@ +#!/usr/bin/env bats + +SCRIPT="scripts/pre-commit-autoupdate-preflight.sh" + +setup() { + export CONFIG_PATH=".pre-commit-config.yaml" + export PRE_COMMIT_VERSION="" + export APP_ID="" + export HAS_APP_PRIVATE_KEY=false +} + +run_preflight() { + run bash "$SCRIPT" +} + +output_value() { + printf '%s\n' "$output" | awk -F= -v key="$1" ' + $1 == key { + sub(/^[^=]*=/, ""); + print; + found=1; + exit + } + END { + if (!found) exit 1 + } + ' +} + +@test "App ID absent and key absent uses GITHUB_TOKEN fallback" { + run_preflight + [ "$status" -eq 0 ] + [ "$(output_value auth_mode)" = "github_token" ] + [ "$(output_value use_fallback_caveat)" = "true" ] +} + +@test "App ID absent and key present still uses GITHUB_TOKEN fallback" { + export HAS_APP_PRIVATE_KEY=true + run_preflight + [ "$status" -eq 0 ] + [ "$(output_value auth_mode)" = "github_token" ] + [ "$(output_value use_fallback_caveat)" = "true" ] +} + +@test "App ID present and key present uses App auth" { + export APP_ID="123456" + export HAS_APP_PRIVATE_KEY=true + run_preflight + [ "$status" -eq 0 ] + [ "$(output_value auth_mode)" = "app" ] + [ "$(output_value use_fallback_caveat)" = "false" ] +} + +@test "App ID present and key absent fails with half-configured error" { + export APP_ID="123456" + export HAS_APP_PRIVATE_KEY=false + run_preflight + [ "$status" -ne 0 ] + [[ "$output" == *"App auth half-configured"* ]] +} + +@test "HAS_APP_PRIVATE_KEY must be true or false" { + export HAS_APP_PRIVATE_KEY=maybe + run_preflight + [ "$status" -ne 0 ] + [[ "$output" == *"HAS_APP_PRIVATE_KEY must be true or false"* ]] +} + +@test "config_path accepts default and emits validated value" { + run_preflight + [ "$status" -eq 0 ] + [ "$(output_value config_path)" = ".pre-commit-config.yaml" ] +} + +@test "config_path accepts safe relative subpath" { + export CONFIG_PATH="tools/pre-commit/.pre-commit-config.yaml" + run_preflight + [ "$status" -eq 0 ] + [ "$(output_value config_path)" = "tools/pre-commit/.pre-commit-config.yaml" ] +} + +@test "config_path rejects empty value" { + export CONFIG_PATH="" + run_preflight + [ "$status" -ne 0 ] + [[ "$output" == *"config_path must not be empty"* ]] +} + +@test "config_path rejects absolute path" { + export CONFIG_PATH="/tmp/.pre-commit-config.yaml" + run_preflight + [ "$status" -ne 0 ] + [[ "$output" == *"config_path must be relative"* ]] +} + +@test "config_path rejects traversal segments" { + for path in "../.pre-commit-config.yaml" "config/../.pre-commit-config.yaml" "config/.." ".."; do + export CONFIG_PATH="$path" + run_preflight + [ "$status" -ne 0 ] + [[ "$output" == *"config_path must not contain '..' path traversal segments"* ]] + done +} + +@test "empty pre_commit_version selects latest uvx mode" { + run_preflight + [ "$status" -eq 0 ] + [ "$(output_value use_pinned_pre_commit)" = "false" ] + [ "$(output_value pre_commit_version)" = "" ] +} + +@test "set pre_commit_version selects pinned uvx mode and emits validated version" { + export PRE_COMMIT_VERSION="4.1.0rc1" + run_preflight + [ "$status" -eq 0 ] + [ "$(output_value use_pinned_pre_commit)" = "true" ] + [ "$(output_value pre_commit_version)" = "4.1.0rc1" ] +} + +@test "pre_commit_version accepts post releases, local suffixes, and epoch marker" { + for version in "3.7.1.post1" "4.0+x" "1!4.0.0"; do + export PRE_COMMIT_VERSION="$version" + run_preflight + [ "$status" -eq 0 ] + [ "$(output_value use_pinned_pre_commit)" = "true" ] + [ "$(output_value pre_commit_version)" = "$version" ] + done +} + +@test "unsafe pre_commit_version characters fail before run-mode output" { + export PRE_COMMIT_VERSION="1.0; echo bad" + run_preflight + [ "$status" -ne 0 ] + [[ "$output" == *"pre_commit_version contains unsupported characters"* ]] + [[ "$output" != *"use_pinned_pre_commit="* ]] +} From d6b6be8ae744c10f391f394683bd597bc92344b7 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 27 Jun 2026 08:03:42 -0700 Subject: [PATCH 2/4] feat: add pre-commit autoupdate preflight --- scripts/pre-commit-autoupdate-preflight.sh | 88 ++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 scripts/pre-commit-autoupdate-preflight.sh diff --git a/scripts/pre-commit-autoupdate-preflight.sh b/scripts/pre-commit-autoupdate-preflight.sh new file mode 100644 index 0000000..99e871f --- /dev/null +++ b/scripts/pre-commit-autoupdate-preflight.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# pre-commit-autoupdate-preflight.sh - validate workflow inputs and emit +# secret-free primitives for the reusable pre-commit autoupdate workflow. +# +# Inputs (env vars): +# CONFIG_PATH pre-commit config path to diff and optionally commit +# PRE_COMMIT_VERSION optional pre-commit runner version +# APP_ID caller repo vars.RELEASE_BOT_APP_ID, if configured +# HAS_APP_PRIVATE_KEY true when the private key secret is present, else false +# +# Outputs (stdout, GitHub output format): +# auth_mode +# config_path +# use_fallback_caveat +# use_pinned_pre_commit +# pre_commit_version + +set -euo pipefail + +fail() { + echo "::error::$*" >&2 + exit 1 +} + +require_bool() { + local name="$1" value="$2" + case "$value" in + true|false) ;; + *) fail "${name} must be true or false" ;; + esac +} + +contains_traversal_segment() { + local path="$1" + case "$path" in + ..|../*|*/..|*/../*) return 0 ;; + *) return 1 ;; + esac +} + +CONFIG_PATH="${CONFIG_PATH:-}" +PRE_COMMIT_VERSION="${PRE_COMMIT_VERSION:-}" +APP_ID="${APP_ID:-}" +HAS_APP_PRIVATE_KEY="${HAS_APP_PRIVATE_KEY:-false}" + +require_bool "HAS_APP_PRIVATE_KEY" "$HAS_APP_PRIVATE_KEY" + +if [ -z "$CONFIG_PATH" ]; then + fail "config_path must not be empty" +fi + +case "$CONFIG_PATH" in + /*) fail "config_path must be relative: $CONFIG_PATH" ;; +esac + +case "$CONFIG_PATH" in + *$'\n'*|*$'\r'*) fail "config_path must not contain newlines" ;; +esac + +if contains_traversal_segment "$CONFIG_PATH"; then + fail "config_path must not contain '..' path traversal segments: $CONFIG_PATH" +fi + +AUTH_MODE="github_token" +USE_FALLBACK_CAVEAT="true" +if [ -n "$APP_ID" ]; then + if [ "$HAS_APP_PRIVATE_KEY" != "true" ]; then + fail "App auth half-configured - set both vars.RELEASE_BOT_APP_ID and RELEASE_BOT_PRIVATE_KEY, or neither." + fi + AUTH_MODE="app" + USE_FALLBACK_CAVEAT="false" +fi + +USE_PINNED_PRE_COMMIT="false" +if [ -n "$PRE_COMMIT_VERSION" ]; then + case "$PRE_COMMIT_VERSION" in + *[!A-Za-z0-9.+_!-]*) + fail "pre_commit_version contains unsupported characters (allowed: A-Z a-z 0-9 . + _ ! -)" + ;; + esac + USE_PINNED_PRE_COMMIT="true" +fi + +printf 'auth_mode=%s\n' "$AUTH_MODE" +printf 'config_path=%s\n' "$CONFIG_PATH" +printf 'use_fallback_caveat=%s\n' "$USE_FALLBACK_CAVEAT" +printf 'use_pinned_pre_commit=%s\n' "$USE_PINNED_PRE_COMMIT" +printf 'pre_commit_version=%s\n' "$PRE_COMMIT_VERSION" From 0ba95c9fc44d24b4f42456c561a2a464b22ba9fc Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 27 Jun 2026 08:08:32 -0700 Subject: [PATCH 3/4] feat: define pre-commit autoupdate workflow contract --- ...e-commit-autoupdate-workflow-contract.bats | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 tests/pre-commit-autoupdate-workflow-contract.bats diff --git a/tests/pre-commit-autoupdate-workflow-contract.bats b/tests/pre-commit-autoupdate-workflow-contract.bats new file mode 100644 index 0000000..a7f4a68 --- /dev/null +++ b/tests/pre-commit-autoupdate-workflow-contract.bats @@ -0,0 +1,305 @@ +#!/usr/bin/env bats +# pre-commit-autoupdate-workflow-contract.bats - static contract tests for the +# reusable pre-commit autoupdate workflow. + +YAML=".github/workflows/pre-commit-autoupdate.yml" +WORKFLOW_README=".github/workflows/README.md" +ROOT_README="README.md" + +assert_eq() { + if [ "$1" != "$2" ]; then + printf 'expected:\n%s\nactual:\n%s\n' "$2" "$1" + return 1 + fi +} + +assert_contains() { + case "$1" in + *"$2"*) return 0 ;; + *) + printf 'expected text to contain:\n%s\n' "$2" + return 1 + ;; + esac +} + +assert_lacks() { + case "$1" in + *"$2"*) + printf 'expected text not to contain:\n%s\n' "$2" + return 1 + ;; + *) return 0 ;; + esac +} + +on_block() { + awk ' + /^on:$/ { flag=1; print; next } + flag && /^[^[:space:]][^:]*:/ { exit } + flag { print } + ' "$YAML" +} + +on_trigger_keys() { + on_block | awk ' + /^ [A-Za-z0-9_-]+:$/ { + sub(/^ /, "", $0); + sub(/:$/, "", $0); + print + } + ' +} + +workflow_call_input_keys() { + on_block | awk ' + /^ workflow_call:$/ { in_call=1; next } + in_call && /^ inputs:$/ { in_inputs=1; next } + in_inputs && /^ [a-z0-9_]+:$/ { + sub(/^ /, "", $0); + sub(/:$/, "", $0); + print; + next + } + in_inputs && /^ [A-Za-z0-9_-]+:/ { exit } + ' +} + +input_block() { + awk -v key=" $1:" ' + $0 == key { flag=1; print; next } + flag && /^ [a-z0-9_]+:$/ { exit } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag && /^[^[:space:]][^:]*:/ { exit } + flag { print } + ' "$YAML" +} + +input_type() { + input_block "$1" | awk '/^ type:/ { sub(/^ type: */, ""); print; exit }' +} + +input_default() { + input_block "$1" | awk '/^ default:/ { sub(/^ default: */, ""); print; exit }' +} + +secret_block() { + on_block | awk -v key=" $1:" ' + /^ secrets:$/ { in_secrets=1; next } + in_secrets && $0 == key { flag=1; print; next } + flag && /^ [A-Z0-9_]+:$/ { exit } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag { print } + ' +} + +job_block() { + awk -v job=" $1:" ' + $0 == job { flag=1; print; next } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag { print } + ' "$YAML" +} + +step_block() { + awk -v name=" - name: $1" ' + $0 == name { flag=1; print; next } + flag && /^ - / { exit } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag { print } + ' "$YAML" +} + +job_permissions_block() { + job_block "$1" | awk ' + /^ permissions:/ { flag=1; print; next } + flag && /^ [A-Za-z0-9_-]+:/ { exit } + flag { print } + ' +} + +run_blocks() { + awk ' + /^ run: \|$/ { in_run=1; next } + in_run && /^ - / { in_run=0; next } + in_run && /^ [A-Za-z0-9_-]+:/ { in_run=0; next } + in_run { print } + ' "$YAML" +} + +workflow_readme_section() { + awk ' + /^## `pre-commit-autoupdate.yml`$/ { flag=1; print; next } + flag && /^## `/ { exit } + flag { print } + ' "$WORKFLOW_README" +} + +root_readme_section() { + awk ' + /^## Pre-commit Autoupdate$/ { flag=1; print; next } + flag && /^## / { exit } + flag { print } + ' "$ROOT_README" +} + +@test "pre-commit-autoupdate.yml is workflow_call only" { + assert_eq "$(on_trigger_keys)" "workflow_call" +} + +@test "public inputs and defaults match the v1 contract" { + expected_inputs=$'branch +commit_message +config_path +labels +pre_commit_version +restrict_paths +sign_commits +title' + + observed_inputs=$(workflow_call_input_keys | sort) + expected_sorted=$(printf "%s\n" "$expected_inputs" | sort) + assert_eq "$observed_inputs" "$expected_sorted" + + assert_eq "$(input_type config_path)" "string" + assert_eq "$(input_default config_path)" '".pre-commit-config.yaml"' + assert_eq "$(input_type branch)" "string" + assert_eq "$(input_default branch)" '"deps/pre-commit-autoupdate"' + assert_eq "$(input_type title)" "string" + assert_eq "$(input_default title)" '"deps: update pre-commit hooks"' + assert_eq "$(input_type commit_message)" "string" + assert_eq "$(input_default commit_message)" '"deps: update pre-commit hooks"' + assert_eq "$(input_type labels)" "string" + assert_eq "$(input_default labels)" '"dependencies"' + assert_eq "$(input_type sign_commits)" "boolean" + assert_eq "$(input_default sign_commits)" "true" + assert_eq "$(input_type restrict_paths)" "boolean" + assert_eq "$(input_default restrict_paths)" "true" + assert_eq "$(input_type pre_commit_version)" "string" + assert_eq "$(input_default pre_commit_version)" '""' +} + +@test "Release Bot private key secret is optional" { + block=$(secret_block RELEASE_BOT_PRIVATE_KEY) + assert_contains "$block" "required: false" +} + +@test "workflow denies permissions at top level and declares the job write union" { + grep -qxF "permissions: {}" "$YAML" + assert_eq "$(job_permissions_block autoupdate)" $' permissions:\n contents: write\n pull-requests: write' +} + +@test "third-party actions are pinned with version comments" { + block=$(job_block autoupdate) + assert_contains "$block" "step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4" + assert_contains "$block" "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0" + assert_contains "$block" "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0" + assert_contains "$block" "astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0" + assert_contains "$block" "peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1" +} + +@test "App token minting is conditional and requests contents plus pull-requests write" { + block=$(step_block "Mint GitHub App token") + assert_contains "$block" "if: steps.preflight.outputs.auth_mode == 'app'" + assert_contains "$block" 'client-id: ${{ vars.RELEASE_BOT_APP_ID }}' + assert_contains "$block" 'private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}' + assert_contains "$block" "permission-contents: write" + assert_contains "$block" "permission-pull-requests: write" +} + +@test "checkout does not persist credentials" { + block=$(step_block "Checkout repository") + assert_contains "$block" "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0" + assert_contains "$block" "persist-credentials: false" +} + +@test "preflight computes private-key presence without passing the secret to the script" { + block=$(step_block "Preflight inputs and auth") + assert_contains "$block" 'APP_PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}' + assert_contains "$block" "unset APP_PRIVATE_KEY" + assert_contains "$block" "HAS_APP_PRIVATE_KEY=true" + assert_contains "$block" "HAS_APP_PRIVATE_KEY=false" + assert_contains "$block" 'pre_commit_autoupdate_preflight >> "$GITHUB_OUTPUT"' +} + +@test "run blocks do not interpolate raw workflow inputs" { + runs=$(run_blocks) + assert_lacks "$runs" '${{ inputs.' +} + +@test "shell steps bind validated preflight outputs after preflight" { + run_block=$(step_block "Run pre-commit autoupdate") + diff_block=$(step_block "Check for changes") + + assert_contains "$run_block" 'USE_PINNED_PRE_COMMIT: ${{ steps.preflight.outputs.use_pinned_pre_commit }}' + assert_contains "$run_block" 'PRE_COMMIT_VERSION: ${{ steps.preflight.outputs.pre_commit_version }}' + assert_contains "$diff_block" 'CONFIG_PATH: ${{ steps.preflight.outputs.config_path }}' + assert_lacks "$run_block" 'PRE_COMMIT_VERSION: ${{ inputs.pre_commit_version }}' + assert_lacks "$diff_block" 'CONFIG_PATH: ${{ inputs.config_path }}' +} + +@test "uvx commands are fixed workflow branches, not emitted command strings" { + block=$(step_block "Run pre-commit autoupdate") + assert_contains "$block" 'if [ "$USE_PINNED_PRE_COMMIT" = "true" ]; then' + assert_contains "$block" 'uvx --from "pre-commit==$PRE_COMMIT_VERSION" pre-commit autoupdate' + assert_contains "$block" "uvx pre-commit autoupdate" + assert_lacks "$block" "eval" +} + +@test "change detection is set -e safe and gates PR creation" { + block=$(step_block "Check for changes") + assert_contains "$block" 'if git diff --quiet "$CONFIG_PATH"; then' + assert_contains "$block" 'echo "changed=false" >> "$GITHUB_OUTPUT"' + assert_contains "$block" 'echo "changed=true" >> "$GITHUB_OUTPUT"' + + restricted=$(step_block "Open restricted PR with updates") + unrestricted=$(step_block "Open unrestricted PR with updates") + assert_contains "$restricted" "steps.diff.outputs.changed == 'true'" + assert_contains "$unrestricted" "steps.diff.outputs.changed == 'true'" +} + +@test "pull request creation has restricted and unrestricted path variants" { + restricted=$(step_block "Open restricted PR with updates") + unrestricted=$(step_block "Open unrestricted PR with updates") + + assert_contains "$restricted" "inputs.restrict_paths" + assert_contains "$restricted" 'add-paths: ${{ inputs.config_path }}' + assert_contains "$restricted" "delete-branch: true" + assert_contains "$restricted" 'token: ${{ steps.app-token.outputs.token || github.token }}' + assert_contains "$restricted" 'body: ${{ steps.pr-body.outputs.body }}' + + assert_contains "$unrestricted" "!inputs.restrict_paths" + assert_lacks "$unrestricted" "add-paths:" + assert_contains "$unrestricted" "delete-branch: true" + assert_contains "$unrestricted" 'token: ${{ steps.app-token.outputs.token || github.token }}' + assert_contains "$unrestricted" 'body: ${{ steps.pr-body.outputs.body }}' +} + +@test "PR body caveat is conditional on fallback auth" { + block=$(step_block "Compose pull request body") + assert_contains "$block" 'USE_FALLBACK_CAVEAT: ${{ steps.preflight.outputs.use_fallback_caveat }}' + assert_contains "$block" 'if [ "$USE_FALLBACK_CAVEAT" = "true" ]; then' + assert_contains "$block" "GITHUB_TOKEN-authored PRs may not trigger required CI automatically" +} + +@test "docs cover App and fallback callers plus operational caveats" { + workflow_docs=$(workflow_readme_section) + root_docs=$(root_readme_section) + + assert_contains "$workflow_docs" "Recommended App-token caller" + assert_contains "$workflow_docs" "Minimal GITHUB_TOKEN fallback caller" + assert_contains "$workflow_docs" "RELEASE_BOT_PRIVATE_KEY: \${{ secrets.RELEASE_BOT_PRIVATE_KEY }}" + assert_contains "$workflow_docs" "contents: read" + assert_contains "$workflow_docs" "pull-requests: write" + assert_contains "$workflow_docs" "GITHUB_TOKEN-authored PRs may not trigger required CI automatically" + assert_contains "$workflow_docs" "pre_commit_version" + assert_contains "$workflow_docs" "restrict_paths" + assert_contains "$workflow_docs" "403" + assert_contains "$workflow_docs" "caller keeps its own schedule" + + assert_contains "$root_docs" "pre-commit-autoupdate.yml" + assert_contains "$root_docs" "uvx" + assert_contains "$root_docs" "App-token" + assert_contains "$root_docs" "GITHUB_TOKEN" +} From 126c6d608a09358dd940908a8a879fe13c5f1900 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 27 Jun 2026 08:25:39 -0700 Subject: [PATCH 4/4] feat: add pre-commit autoupdate workflow --- .github/workflows/README.md | 97 +++++++ .github/workflows/pre-commit-autoupdate.yml | 250 ++++++++++++++++++ README.md | 41 +++ scripts/check-inline-sync.sh | 1 + ...e-commit-autoupdate-workflow-contract.bats | 8 +- 5 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/pre-commit-autoupdate.yml diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 06e518e..be9d1dc 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -4,6 +4,103 @@ This directory hosts reusable workflows under `j7an/shared-workflows`. Consumers > **Note:** `@v3` and `@v2` continue to work at their last-released revisions, but receive no further updates. See the root README's "v3 → v4 migration" section. +## `pre-commit-autoupdate.yml` + +Runs `pre-commit autoupdate` for consumer repos, detects changes to the +configured pre-commit config file, and opens a dependency-update pull request. +The workflow is `workflow_call`-only: each caller keeps its own schedule, +manual dispatch trigger, branch filters, and optional concurrency. + +The runner path is language-neutral but uv-runner scoped. The consumer repo +needs a pre-commit config file (defaults to `.pre-commit-config.yaml` via +`config_path`); it does not need to be a Python or uv project because the +reusable workflow installs uv and runs `uvx`. + +### Recommended App-token caller + +Use this shape for repos with required checks or branch protection. The caller +repository must define `vars.RELEASE_BOT_APP_ID` and the +`RELEASE_BOT_PRIVATE_KEY` secret. + +```yaml +name: Pre-commit Autoupdate + +on: + schedule: + - cron: "0 8 * * 1" + workflow_dispatch: + +permissions: {} + +concurrency: + group: pre-commit-autoupdate + cancel-in-progress: true + +jobs: + autoupdate: + permissions: + contents: read + uses: j7an/shared-workflows/.github/workflows/pre-commit-autoupdate.yml@v4 + secrets: + RELEASE_BOT_PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} +``` + +Pass the private key explicitly rather than using `secrets: inherit`. The App +token is minted inside the reusable workflow with contents and pull-request +write scopes, while the caller's automatic `GITHUB_TOKEN` remains read-only. + +### Minimal GITHUB_TOKEN fallback caller + +Use this shape only for repos that accept the trigger caveat or have no +required checks on the generated PRs. + +```yaml +name: Pre-commit Autoupdate + +on: + schedule: + - cron: "0 8 * * 1" + workflow_dispatch: + +permissions: {} + +concurrency: + group: pre-commit-autoupdate + cancel-in-progress: true + +jobs: + autoupdate: + permissions: + contents: write + pull-requests: write + uses: j7an/shared-workflows/.github/workflows/pre-commit-autoupdate.yml@v4 +``` + +No App var and no private-key secret are passed. If PR creation fails with a +403 in this mode, grant `contents: write` and `pull-requests: write` on the +caller job. + +GITHUB_TOKEN-authored PRs may not trigger required CI automatically due to +GitHub's recursion guard. If checks do not start, close and reopen the PR or +push an empty commit. Prefer the App-token caller for repos with required +checks. + +### Inputs + +| Input | Type | Required | Default | Description | +|---|---|---|---|---| +| `config_path` | string | no | `.pre-commit-config.yaml` | File to check for changes and, when `restrict_paths` is true, the only path committed. | +| `branch` | string | no | `deps/pre-commit-autoupdate` | Pull request branch. | +| `title` | string | no | `deps: update pre-commit hooks` | Pull request title. | +| `commit_message` | string | no | `deps: update pre-commit hooks` | Commit message for hook updates. | +| `labels` | string | no | `dependencies` | Labels passed to `create-pull-request`. | +| `sign_commits` | boolean | no | `true` | Whether `create-pull-request` signs commits. | +| `restrict_paths` | boolean | no | `true` | When true, passes `add-paths: config_path` so only the pre-commit config is committed. | +| `pre_commit_version` | string | no | `""` | Optional pre-commit runner version. Empty uses latest; set it as a regression circuit-breaker. | + +`delete-branch: true` is standardized by the reusable workflow, so recurring +automation branches are cleaned up after merge. + ## `security-scan.yml` Runs the shared security scanning baseline for sibling repos: CodeQL, diff --git a/.github/workflows/pre-commit-autoupdate.yml b/.github/workflows/pre-commit-autoupdate.yml new file mode 100644 index 0000000..394d0fe --- /dev/null +++ b/.github/workflows/pre-commit-autoupdate.yml @@ -0,0 +1,250 @@ +name: Pre-commit Autoupdate + +on: + workflow_call: + inputs: + config_path: + type: string + default: ".pre-commit-config.yaml" + description: "Pre-commit config path to update, diff, and optionally restrict commits to." + branch: + type: string + default: "deps/pre-commit-autoupdate" + description: "Pull request branch." + title: + type: string + default: "deps: update pre-commit hooks" + description: "Pull request title." + commit_message: + type: string + default: "deps: update pre-commit hooks" + description: "Commit message for hook updates." + labels: + type: string + default: "dependencies" + description: "Labels passed to create-pull-request." + sign_commits: + type: boolean + default: true + description: "Whether create-pull-request signs commits." + restrict_paths: + type: boolean + default: true + description: "Restrict the PR commit to config_path." + pre_commit_version: + type: string + default: "" + description: "Optional pre-commit runner version. Empty uses latest." + secrets: + RELEASE_BOT_PRIVATE_KEY: + required: false + +permissions: {} + +jobs: + autoupdate: + name: Autoupdate + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Preflight inputs and auth + id: preflight + env: + APP_ID: ${{ vars.RELEASE_BOT_APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + CONFIG_PATH: ${{ inputs.config_path }} + PRE_COMMIT_VERSION: ${{ inputs.pre_commit_version }} + run: | + if [ -n "$APP_PRIVATE_KEY" ]; then + HAS_APP_PRIVATE_KEY=true + else + HAS_APP_PRIVATE_KEY=false + fi + unset APP_PRIVATE_KEY + export APP_ID HAS_APP_PRIVATE_KEY CONFIG_PATH PRE_COMMIT_VERSION + + # --- BEGIN inline:scripts/pre-commit-autoupdate-preflight.sh --- + pre_commit_autoupdate_preflight() ( + # pre-commit-autoupdate-preflight.sh - validate workflow inputs and emit + # secret-free primitives for the reusable pre-commit autoupdate workflow. + # + # Inputs (env vars): + # CONFIG_PATH pre-commit config path to diff and optionally commit + # PRE_COMMIT_VERSION optional pre-commit runner version + # APP_ID caller repo vars.RELEASE_BOT_APP_ID, if configured + # HAS_APP_PRIVATE_KEY true when the private key secret is present, else false + # + # Outputs (stdout, GitHub output format): + # auth_mode + # config_path + # use_fallback_caveat + # use_pinned_pre_commit + # pre_commit_version + + set -euo pipefail + + fail() { + echo "::error::$*" >&2 + exit 1 + } + + require_bool() { + local name="$1" value="$2" + case "$value" in + true|false) ;; + *) fail "${name} must be true or false" ;; + esac + } + + contains_traversal_segment() { + local path="$1" + case "$path" in + ..|../*|*/..|*/../*) return 0 ;; + *) return 1 ;; + esac + } + + CONFIG_PATH="${CONFIG_PATH:-}" + PRE_COMMIT_VERSION="${PRE_COMMIT_VERSION:-}" + APP_ID="${APP_ID:-}" + HAS_APP_PRIVATE_KEY="${HAS_APP_PRIVATE_KEY:-false}" + + require_bool "HAS_APP_PRIVATE_KEY" "$HAS_APP_PRIVATE_KEY" + + if [ -z "$CONFIG_PATH" ]; then + fail "config_path must not be empty" + fi + + case "$CONFIG_PATH" in + /*) fail "config_path must be relative: $CONFIG_PATH" ;; + esac + + case "$CONFIG_PATH" in + *$'\n'*|*$'\r'*) fail "config_path must not contain newlines" ;; + esac + + if contains_traversal_segment "$CONFIG_PATH"; then + fail "config_path must not contain '..' path traversal segments: $CONFIG_PATH" + fi + + AUTH_MODE="github_token" + USE_FALLBACK_CAVEAT="true" + if [ -n "$APP_ID" ]; then + if [ "$HAS_APP_PRIVATE_KEY" != "true" ]; then + fail "App auth half-configured - set both vars.RELEASE_BOT_APP_ID and RELEASE_BOT_PRIVATE_KEY, or neither." + fi + AUTH_MODE="app" + USE_FALLBACK_CAVEAT="false" + fi + + USE_PINNED_PRE_COMMIT="false" + if [ -n "$PRE_COMMIT_VERSION" ]; then + case "$PRE_COMMIT_VERSION" in + *[!A-Za-z0-9.+_!-]*) + fail "pre_commit_version contains unsupported characters (allowed: A-Z a-z 0-9 . + _ ! -)" + ;; + esac + USE_PINNED_PRE_COMMIT="true" + fi + + printf 'auth_mode=%s\n' "$AUTH_MODE" + printf 'config_path=%s\n' "$CONFIG_PATH" + printf 'use_fallback_caveat=%s\n' "$USE_FALLBACK_CAVEAT" + printf 'use_pinned_pre_commit=%s\n' "$USE_PINNED_PRE_COMMIT" + printf 'pre_commit_version=%s\n' "$PRE_COMMIT_VERSION" + ) + # --- END inline:scripts/pre-commit-autoupdate-preflight.sh --- + + pre_commit_autoupdate_preflight >> "$GITHUB_OUTPUT" + + - name: Mint GitHub App token + id: app-token + if: steps.preflight.outputs.auth_mode == 'app' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + + - name: Run pre-commit autoupdate + env: + USE_PINNED_PRE_COMMIT: ${{ steps.preflight.outputs.use_pinned_pre_commit }} + PRE_COMMIT_VERSION: ${{ steps.preflight.outputs.pre_commit_version }} + CONFIG_PATH: ${{ steps.preflight.outputs.config_path }} + run: | + if [ "$USE_PINNED_PRE_COMMIT" = "true" ]; then + uvx --from "pre-commit==$PRE_COMMIT_VERSION" pre-commit autoupdate -c "$CONFIG_PATH" + else + uvx pre-commit autoupdate -c "$CONFIG_PATH" + fi + + - name: Check for changes + id: diff + env: + CONFIG_PATH: ${{ steps.preflight.outputs.config_path }} + run: | + if git diff --quiet -- "$CONFIG_PATH"; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Compose pull request body + id: pr-body + env: + USE_FALLBACK_CAVEAT: ${{ steps.preflight.outputs.use_fallback_caveat }} + run: | + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Open restricted PR with updates + if: ${{ steps.diff.outputs.changed == 'true' && inputs.restrict_paths }} + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token || github.token }} + branch: ${{ inputs.branch }} + title: ${{ inputs.title }} + commit-message: ${{ inputs.commit_message }} + body: ${{ steps.pr-body.outputs.body }} + labels: ${{ inputs.labels }} + sign-commits: ${{ inputs.sign_commits }} + delete-branch: true + add-paths: ${{ inputs.config_path }} + + - name: Open unrestricted PR with updates + if: ${{ steps.diff.outputs.changed == 'true' && !inputs.restrict_paths }} + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token || github.token }} + branch: ${{ inputs.branch }} + title: ${{ inputs.title }} + commit-message: ${{ inputs.commit_message }} + body: ${{ steps.pr-body.outputs.body }} + labels: ${{ inputs.labels }} + sign-commits: ${{ inputs.sign_commits }} + delete-branch: true diff --git a/README.md b/README.md index 9c11ffa..0d087f2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Reusable GitHub Actions workflows for dependency safety verification and release - **Update-or-create scan comments** — a single stable comment per PR; change detection posts a top-level PR comment only when advisory IDs actually change - **Auto-merge by default** — clean scans enable `gh pr merge --auto` (set `auto_merge: false` to opt out); dirty scans apply labels (`security-review-needed`, `dependency-age-violation`, or `dependency-safety-error`) instead - **Grouped PR support** — handles both single-package and grouped Dependabot PRs +- **Reusable pre-commit autoupdate PRs** — shared `pre-commit-autoupdate.yml` runs `uvx pre-commit autoupdate`, opens a dependency PR only when the configured pre-commit config changes, recommends Release Bot App auth for required checks, and keeps a documented `GITHUB_TOKEN` fallback ## Prerequisites @@ -93,6 +94,46 @@ jobs: > job stays green rather than failing. If you make that status **required**, see > [Fork PRs and the required gate](#fork-prs-and-the-required-gate). +## Pre-commit Autoupdate + +`pre-commit-autoupdate.yml` is a `workflow_call`-only reusable workflow for +repos that keep a pre-commit config file (default path `.pre-commit-config.yaml` +via `config_path`). Callers keep their own `schedule`, +`workflow_dispatch`, and optional `concurrency`; the shared workflow installs +uv and runs `uvx`, so the caller repo does not need to be a uv-managed Python +project. + +Prefer the App-token caller for repos with required checks: + +```yaml +name: Pre-commit Autoupdate + +on: + schedule: + - cron: "0 8 * * 1" + workflow_dispatch: + +permissions: {} + +concurrency: + group: pre-commit-autoupdate + cancel-in-progress: true + +jobs: + autoupdate: + permissions: + contents: read + uses: j7an/shared-workflows/.github/workflows/pre-commit-autoupdate.yml@v4 + secrets: + RELEASE_BOT_PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} +``` + +The caller repo must define `vars.RELEASE_BOT_APP_ID`. Without that var, the +workflow falls back to `GITHUB_TOKEN`; fallback callers must grant +`contents: write` and `pull-requests: write`, and their generated PRs may need +a close/reopen or empty commit to start required CI because of GitHub's +recursion guard. + ## Inputs | Input | Type | Default | Description | diff --git a/scripts/check-inline-sync.sh b/scripts/check-inline-sync.sh index c1dd5e9..5133f0a 100755 --- a/scripts/check-inline-sync.sh +++ b/scripts/check-inline-sync.sh @@ -18,6 +18,7 @@ INLINE_PAIRS=( ".github/workflows/dependency-safety.yml:scripts/safety-verdict.sh" ".github/workflows/dependency-safety.yml:scripts/classify-touched-paths.sh" ".github/workflows/tag-release.yml:scripts/bump-version-files.sh" + ".github/workflows/pre-commit-autoupdate.yml:scripts/pre-commit-autoupdate-preflight.sh" ".github/workflows/dependency-safety.yml:scripts/pyproject-bump-extract.sh" ) diff --git a/tests/pre-commit-autoupdate-workflow-contract.bats b/tests/pre-commit-autoupdate-workflow-contract.bats index a7f4a68..0a1b27d 100644 --- a/tests/pre-commit-autoupdate-workflow-contract.bats +++ b/tests/pre-commit-autoupdate-workflow-contract.bats @@ -234,22 +234,24 @@ title' assert_contains "$run_block" 'USE_PINNED_PRE_COMMIT: ${{ steps.preflight.outputs.use_pinned_pre_commit }}' assert_contains "$run_block" 'PRE_COMMIT_VERSION: ${{ steps.preflight.outputs.pre_commit_version }}' + assert_contains "$run_block" 'CONFIG_PATH: ${{ steps.preflight.outputs.config_path }}' assert_contains "$diff_block" 'CONFIG_PATH: ${{ steps.preflight.outputs.config_path }}' assert_lacks "$run_block" 'PRE_COMMIT_VERSION: ${{ inputs.pre_commit_version }}' + assert_lacks "$run_block" 'CONFIG_PATH: ${{ inputs.config_path }}' assert_lacks "$diff_block" 'CONFIG_PATH: ${{ inputs.config_path }}' } @test "uvx commands are fixed workflow branches, not emitted command strings" { block=$(step_block "Run pre-commit autoupdate") assert_contains "$block" 'if [ "$USE_PINNED_PRE_COMMIT" = "true" ]; then' - assert_contains "$block" 'uvx --from "pre-commit==$PRE_COMMIT_VERSION" pre-commit autoupdate' - assert_contains "$block" "uvx pre-commit autoupdate" + assert_contains "$block" 'uvx --from "pre-commit==$PRE_COMMIT_VERSION" pre-commit autoupdate -c "$CONFIG_PATH"' + assert_contains "$block" 'uvx pre-commit autoupdate -c "$CONFIG_PATH"' assert_lacks "$block" "eval" } @test "change detection is set -e safe and gates PR creation" { block=$(step_block "Check for changes") - assert_contains "$block" 'if git diff --quiet "$CONFIG_PATH"; then' + assert_contains "$block" 'if git diff --quiet -- "$CONFIG_PATH"; then' assert_contains "$block" 'echo "changed=false" >> "$GITHUB_OUTPUT"' assert_contains "$block" 'echo "changed=true" >> "$GITHUB_OUTPUT"'