Add hooks to run shell snippets around git operations (#411)

* feat: add _run_hook helper and eight hook inputs

* fix(_run_hook): default snippet to empty when arg unset

* feat: wire add/commit hooks into _main

* feat: wire tag and push hooks into _main

* test: verify failing hook aborts the action

* docs: document hooks system in README

* test: tighten failing-hook assertion

* docs(hooks): note set -eu semantics for snippet authors

* refactor(hooks): rename action inputs to suffix with _hook

* refactor(hooks): include _hook suffix in event identifier

* docs(hooks): add notes about security implications of hooks
This commit is contained in:
Stefan Zweifel
2026-06-28 10:52:27 +02:00
committed by GitHub
parent c365a749b4
commit 9f6c933320
4 changed files with 360 additions and 2 deletions
+96
View File
@@ -141,6 +141,19 @@ The following is an extended example with all available options.
# `pull_request_target` event. See the "Workflow should run in **base** repository" # `pull_request_target` event. See the "Workflow should run in **base** repository"
# section below for context before disabling this warning. # section below for context before disabling this warning.
disable_pull_request_target_trigger_warning: false disable_pull_request_target_trigger_warning: false
# Optional. Shell snippets to run around each git operation. Each hook
# is evaluated in the same bash process as the action — `set -eu` is
# in effect, the working directory is your repository, and all
# `INPUT_*` env vars are visible. A non-zero exit aborts the action.
before_add_hook: 'git fetch --unshallow'
after_add_hook: ''
before_commit_hook: ''
after_commit_hook: ''
before_tag_hook: ''
after_tag_hook: ''
before_push_hook: ''
after_push_hook: ''
``` ```
Please note that the Action depends on `bash`. If you're using the Action in a job in combination with a custom Docker container, make sure that `bash` is installed. Please note that the Action depends on `bash`. If you're using the Action in a job in combination with a custom Docker container, make sure that `bash` is installed.
@@ -215,6 +228,87 @@ You can use these outputs to trigger other Actions in your Workflow run based on
run: echo "No Changes!" run: echo "No Changes!"
``` ```
## Hooks
git-auto-commit can run custom shell snippets around each git operation
it performs. This is useful when you need to prepare or clean up the
repository as part of the same step — for example, unshallowing a
shallow clone right before the commit is staged.
Eight optional hooks are available:
| Hook | Runs |
| ---- | ---- |
| `before_add_hook` / `after_add_hook` | around `git add` |
| `before_commit_hook` / `after_commit_hook` | around `git commit` |
| `before_tag_hook` / `after_tag_hook` | around `git tag` (only when a tag is being created) |
| `before_push_hook` / `after_push_hook` | around `git push` (skipped when `skip_push: true`) |
Each hook is an inline shell snippet that runs in the same bash process
as the action. The working directory is your repository, and all
`INPUT_*` environment variables and standard GitHub Actions env vars are
visible to the snippet.
### Example
```yaml
- uses: stefanzweifel/git-auto-commit-action@v7
with:
before_add_hook: |
git fetch --unshallow
```
Multi-line snippets work via YAML's `|` block scalar:
```yaml
- uses: stefanzweifel/git-auto-commit-action@v7
with:
before_commit_hook: |
echo "About to commit at $(date)"
./scripts/prepare-commit.sh
```
### Notes
- A hook only runs when its underlying step actually runs. For example,
`before_add_hook`/`after_add_hook` are skipped when the working tree is clean,
and `before_push_hook`/`after_push_hook` are skipped when `skip_push: true`.
- If a hook exits with a non-zero status, the action fails. Append
`|| true` to a snippet to ignore its failure.
- Hooks share environment with the action, so they can read action
inputs (e.g. `$INPUT_COMMIT_MESSAGE`) and write to `$GITHUB_OUTPUT`.
- Snippets run under `set -eu`. Referencing an unset variable aborts the
action; use `${VAR:-}` to default an optional variable to empty.
### Security
Hook snippets are evaluated as shell code in the same process as the
action. Treat them as you would any `run:` step.
> [!CAUTION]
> **Do not combine hooks with the `pull_request_target` event when the
> snippet references attacker-controlled GitHub context.** Fields like
> `${{ github.event.pull_request.title }}`, `${{ github.event.pull_request.body }}`,
> `${{ github.head_ref }}`, and commit messages from a fork are
> interpolated into the snippet **before** bash sees it. A malicious PR
> can inject shell commands that run on your runner with access to your
> repository secrets. See the [`pull_request_target` section](#workflow-should-run-in-base-repository)
> for the broader risk and [GitHub's script-injection guidance](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections).
If you need values from PR-controlled context inside a hook, pass them
via an intermediate env var rather than interpolating them directly into
the snippet:
```yaml
- uses: stefanzweifel/git-auto-commit-action@v7
env:
PR_TITLE: ${{ github.event.pull_request.title }}
with:
before_commit_hook: |
# $PR_TITLE is read as data, not evaluated as code
echo "PR: $PR_TITLE"
```
## Limitations & Gotchas ## Limitations & Gotchas
The goal of this Action is to be "the Action for committing files for the 80% use case". Therefore, you might run into issues if your Workflow falls into the not supported 20% portion. The goal of this Action is to be "the Action for committing files for the 80% use case". Therefore, you might run into issues if your Workflow falls into the not supported 20% portion.
@@ -368,6 +462,8 @@ However, there are a couple of ways to use this Action in Workflows that should
> >
> To remind users of this risk, git-auto-commit emits a warning annotation whenever it detects it is running on a `pull_request_target` event. > To remind users of this risk, git-auto-commit emits a warning annotation whenever it detects it is running on a `pull_request_target` event.
> If you have evaluated the risk and want to silence the warning, set the `disable_pull_request_target_trigger_warning` input to `true`. > If you have evaluated the risk and want to silence the warning, set the `disable_pull_request_target_trigger_warning` input to `true`.
>
> **Extra caution if you also use [hooks](#hooks):** hook snippets are evaluated as shell code. Interpolating attacker-controlled fields (PR title/body, branch name, fork commit messages, etc.) directly into a hook input on a `pull_request_target` workflow lets a malicious PR run arbitrary commands on your runner with access to your secrets. Pass such values through an `env:` block and reference them as `$VARS` inside the snippet — see the [Security note in the Hooks section](#security) for an example.
The workflow below runs whenever a commit is pushed to the `main`-branch or when activity on a pull request happens, by listening to the [`pull_request_target`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target) event. The workflow below runs whenever a commit is pushed to the `main`-branch or when activity on a pull request happens, by listening to the [`pull_request_target`](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request_target) event.
+32
View File
@@ -84,6 +84,38 @@ inputs:
description: Perform a clean git tag and push, without commiting anything description: Perform a clean git tag and push, without commiting anything
required: false required: false
default: false default: false
before_add_hook:
description: Shell snippet to run before `git add`.
required: false
default: ''
after_add_hook:
description: Shell snippet to run after `git add`.
required: false
default: ''
before_commit_hook:
description: Shell snippet to run before `git commit`.
required: false
default: ''
after_commit_hook:
description: Shell snippet to run after `git commit`.
required: false
default: ''
before_tag_hook:
description: Shell snippet to run before `git tag` is created.
required: false
default: ''
after_tag_hook:
description: Shell snippet to run after `git tag` is created.
required: false
default: ''
before_push_hook:
description: Shell snippet to run before `git push`.
required: false
default: ''
after_push_hook:
description: Shell snippet to run after `git push`.
required: false
default: ''
disable_pull_request_target_trigger_warning: disable_pull_request_target_trigger_warning:
description: Suppress the security warning emitted when the action runs on a `pull_request_target` event. description: Suppress the security warning emitted when the action runs on a `pull_request_target` event.
required: false required: false
+42 -2
View File
@@ -26,6 +26,16 @@ _log() {
echo "::$level::$message"; echo "::$level::$message";
} }
_run_hook() {
local name=${1}
local snippet=${2:-}
if [ -n "$snippet" ]; then
_log "debug" "Running $name";
eval "$snippet"
fi
}
_main() { _main() {
_check_if_git_is_available _check_if_git_is_available
@@ -40,26 +50,56 @@ _main() {
if "$INPUT_CREATE_GIT_TAG_ONLY"; then if "$INPUT_CREATE_GIT_TAG_ONLY"; then
_log "debug" "Create git tag only"; _log "debug" "Create git tag only";
_set_github_output "create_git_tag_only" "true" _set_github_output "create_git_tag_only" "true"
_tag_commit
if [ -n "$INPUT_TAG_NAME" ] || [ -n "$INPUT_TAGGING_MESSAGE" ]; then
_run_hook "before_tag_hook" "$INPUT_BEFORE_TAG_HOOK"
_tag_commit
_run_hook "after_tag_hook" "$INPUT_AFTER_TAG_HOOK"
else
_tag_commit
fi
if ! "$INPUT_SKIP_PUSH"; then
_run_hook "before_push_hook" "$INPUT_BEFORE_PUSH_HOOK"
fi
_push_to_github _push_to_github
if ! "$INPUT_SKIP_PUSH"; then
_run_hook "after_push_hook" "$INPUT_AFTER_PUSH_HOOK"
fi
elif _git_is_dirty || "$INPUT_SKIP_DIRTY_CHECK"; then elif _git_is_dirty || "$INPUT_SKIP_DIRTY_CHECK"; then
_set_github_output "changes_detected" "true" _set_github_output "changes_detected" "true"
_switch_to_branch _switch_to_branch
_run_hook "before_add_hook" "$INPUT_BEFORE_ADD_HOOK"
_add_files _add_files
_run_hook "after_add_hook" "$INPUT_AFTER_ADD_HOOK"
# Check dirty state of repo again using git-diff. # Check dirty state of repo again using git-diff.
# (git-diff detects better if CRLF of files changes and does NOT # (git-diff detects better if CRLF of files changes and does NOT
# proceed, if only CRLF changes are detected. See #241 and #265 # proceed, if only CRLF changes are detected. See #241 and #265
# for more details.) # for more details.)
if [ -n "$(git diff --staged)" ] || "$INPUT_SKIP_DIRTY_CHECK"; then if [ -n "$(git diff --staged)" ] || "$INPUT_SKIP_DIRTY_CHECK"; then
_run_hook "before_commit_hook" "$INPUT_BEFORE_COMMIT_HOOK"
_local_commit _local_commit
_run_hook "after_commit_hook" "$INPUT_AFTER_COMMIT_HOOK"
_tag_commit if [ -n "$INPUT_TAG_NAME" ] || [ -n "$INPUT_TAGGING_MESSAGE" ]; then
_run_hook "before_tag_hook" "$INPUT_BEFORE_TAG_HOOK"
_tag_commit
_run_hook "after_tag_hook" "$INPUT_AFTER_TAG_HOOK"
else
_tag_commit
fi
if ! "$INPUT_SKIP_PUSH"; then
_run_hook "before_push_hook" "$INPUT_BEFORE_PUSH_HOOK"
fi
_push_to_github _push_to_github
if ! "$INPUT_SKIP_PUSH"; then
_run_hook "after_push_hook" "$INPUT_AFTER_PUSH_HOOK"
fi
else else
_set_github_output "changes_detected" "false" _set_github_output "changes_detected" "false"
+190
View File
@@ -43,6 +43,14 @@ setup() {
export INPUT_CREATE_BRANCH=false export INPUT_CREATE_BRANCH=false
export INPUT_DISABLE_PULL_REQUEST_TARGET_TRIGGER_WARNING=false export INPUT_DISABLE_PULL_REQUEST_TARGET_TRIGGER_WARNING=false
export INPUT_INTERNAL_GIT_BINARY=git export INPUT_INTERNAL_GIT_BINARY=git
export INPUT_BEFORE_ADD_HOOK=""
export INPUT_AFTER_ADD_HOOK=""
export INPUT_BEFORE_COMMIT_HOOK=""
export INPUT_AFTER_COMMIT_HOOK=""
export INPUT_BEFORE_TAG_HOOK=""
export INPUT_AFTER_TAG_HOOK=""
export INPUT_BEFORE_PUSH_HOOK=""
export INPUT_AFTER_PUSH_HOOK=""
# Unset the GitHub event name by default so tests do not pick up # Unset the GitHub event name by default so tests do not pick up
# pull_request_target from the environment of the shell running BATS. # pull_request_target from the environment of the shell running BATS.
@@ -1572,3 +1580,185 @@ END
assert_success assert_success
refute_output --partial "::warning::git-auto-commit is running on a 'pull_request_target' event." refute_output --partial "::warning::git-auto-commit is running on a 'pull_request_target' event."
} }
@test "It does not log a hook line when no hooks are set" {
touch "${FAKE_LOCAL_REPOSITORY}"/new-file-1.txt
run git_auto_commit
assert_success
refute_output --partial "::debug::Running"
}
@test "It runs the before_add hook before git add" {
touch "${FAKE_LOCAL_REPOSITORY}"/new-file-1.txt
export INPUT_BEFORE_ADD_HOOK="echo BEFORE_ADD_RAN > '${FAKE_LOCAL_REPOSITORY}/before-add-marker.txt'"
run git_auto_commit
assert_success
assert_line "::debug::Running before_add_hook"
[ -f "${FAKE_LOCAL_REPOSITORY}/before-add-marker.txt" ]
}
@test "It runs the after_add hook after git add" {
touch "${FAKE_LOCAL_REPOSITORY}"/new-file-1.txt
export INPUT_AFTER_ADD_HOOK="echo AFTER_ADD_RAN > '${FAKE_LOCAL_REPOSITORY}/after-add-marker.txt'"
run git_auto_commit
assert_success
assert_line "::debug::Running after_add_hook"
[ -f "${FAKE_LOCAL_REPOSITORY}/after-add-marker.txt" ]
}
@test "It runs the before_commit hook before creating the commit" {
touch "${FAKE_LOCAL_REPOSITORY}"/new-file-1.txt
export INPUT_BEFORE_COMMIT_HOOK="echo BEFORE_COMMIT_RAN > '${FAKE_LOCAL_REPOSITORY}/before-commit-marker.txt'"
run git_auto_commit
assert_success
assert_line "::debug::Running before_commit_hook"
[ -f "${FAKE_LOCAL_REPOSITORY}/before-commit-marker.txt" ]
# The marker was created after `git add` ran, so it is NOT in the commit.
run git show --name-only HEAD
refute_output --partial "before-commit-marker.txt"
}
@test "It runs the after_commit hook after creating the commit" {
touch "${FAKE_LOCAL_REPOSITORY}"/new-file-1.txt
export INPUT_AFTER_COMMIT_HOOK="git rev-parse HEAD > '${FAKE_LOCAL_REPOSITORY}/after-commit-sha.txt'"
run git_auto_commit
assert_success
assert_line "::debug::Running after_commit_hook"
[ -f "${FAKE_LOCAL_REPOSITORY}/after-commit-sha.txt" ]
# Sanity check: the SHA written is a valid commit hash
run cat "${FAKE_LOCAL_REPOSITORY}/after-commit-sha.txt"
assert_output --regexp '^[0-9a-f]{40}$'
}
@test "It does not run add or commit hooks when the working tree is clean" {
export INPUT_BEFORE_ADD_HOOK="echo SHOULD_NOT_RUN > '${FAKE_LOCAL_REPOSITORY}/before-add-marker.txt'"
export INPUT_AFTER_ADD_HOOK="echo SHOULD_NOT_RUN > '${FAKE_LOCAL_REPOSITORY}/after-add-marker.txt'"
export INPUT_BEFORE_COMMIT_HOOK="echo SHOULD_NOT_RUN > '${FAKE_LOCAL_REPOSITORY}/before-commit-marker.txt'"
export INPUT_AFTER_COMMIT_HOOK="echo SHOULD_NOT_RUN > '${FAKE_LOCAL_REPOSITORY}/after-commit-marker.txt'"
run git_auto_commit
assert_success
assert_line "Working tree clean. Nothing to commit."
refute_output --partial "::debug::Running"
[ ! -f "${FAKE_LOCAL_REPOSITORY}/before-add-marker.txt" ]
[ ! -f "${FAKE_LOCAL_REPOSITORY}/after-add-marker.txt" ]
[ ! -f "${FAKE_LOCAL_REPOSITORY}/before-commit-marker.txt" ]
[ ! -f "${FAKE_LOCAL_REPOSITORY}/after-commit-marker.txt" ]
}
@test "It runs before_tag and after_tag hooks when creating a tag" {
INPUT_TAG_NAME="v1.0.0"
INPUT_TAGGING_MESSAGE="Release v1.0.0"
touch "${FAKE_LOCAL_REPOSITORY}"/new-file-1.txt
export INPUT_BEFORE_TAG_HOOK="echo BEFORE_TAG_RAN > '${FAKE_LOCAL_REPOSITORY}/before-tag-marker.txt'"
export INPUT_AFTER_TAG_HOOK="echo AFTER_TAG_RAN > '${FAKE_LOCAL_REPOSITORY}/after-tag-marker.txt'"
run git_auto_commit
assert_success
assert_line "::debug::Running before_tag_hook"
assert_line "::debug::Running after_tag_hook"
[ -f "${FAKE_LOCAL_REPOSITORY}/before-tag-marker.txt" ]
[ -f "${FAKE_LOCAL_REPOSITORY}/after-tag-marker.txt" ]
}
@test "It does not run tag hooks when no tag name or tagging message is set" {
touch "${FAKE_LOCAL_REPOSITORY}"/new-file-1.txt
export INPUT_BEFORE_TAG_HOOK="echo SHOULD_NOT_RUN > '${FAKE_LOCAL_REPOSITORY}/before-tag-marker.txt'"
export INPUT_AFTER_TAG_HOOK="echo SHOULD_NOT_RUN > '${FAKE_LOCAL_REPOSITORY}/after-tag-marker.txt'"
run git_auto_commit
assert_success
refute_line "::debug::Running before_tag_hook"
refute_line "::debug::Running after_tag_hook"
[ ! -f "${FAKE_LOCAL_REPOSITORY}/before-tag-marker.txt" ]
[ ! -f "${FAKE_LOCAL_REPOSITORY}/after-tag-marker.txt" ]
}
@test "It runs tag hooks under create_git_tag_only mode" {
INPUT_CREATE_GIT_TAG_ONLY=true
INPUT_TAG_NAME="v1.0.0"
INPUT_TAGGING_MESSAGE="Release v1.0.0"
export INPUT_BEFORE_TAG_HOOK="echo BEFORE_TAG_RAN > '${FAKE_LOCAL_REPOSITORY}/before-tag-marker.txt'"
export INPUT_AFTER_TAG_HOOK="echo AFTER_TAG_RAN > '${FAKE_LOCAL_REPOSITORY}/after-tag-marker.txt'"
run git_auto_commit
assert_success
assert_line "::debug::Running before_tag_hook"
assert_line "::debug::Running after_tag_hook"
[ -f "${FAKE_LOCAL_REPOSITORY}/before-tag-marker.txt" ]
[ -f "${FAKE_LOCAL_REPOSITORY}/after-tag-marker.txt" ]
}
@test "It runs before_push and after_push hooks around git push" {
touch "${FAKE_LOCAL_REPOSITORY}"/new-file-1.txt
export INPUT_BEFORE_PUSH_HOOK="echo BEFORE_PUSH_RAN > '${FAKE_LOCAL_REPOSITORY}/before-push-marker.txt'"
export INPUT_AFTER_PUSH_HOOK="echo AFTER_PUSH_RAN > '${FAKE_LOCAL_REPOSITORY}/after-push-marker.txt'"
run git_auto_commit
assert_success
assert_line "::debug::Running before_push_hook"
assert_line "::debug::Running after_push_hook"
[ -f "${FAKE_LOCAL_REPOSITORY}/before-push-marker.txt" ]
[ -f "${FAKE_LOCAL_REPOSITORY}/after-push-marker.txt" ]
}
@test "It does not run push hooks when skip_push is true" {
INPUT_SKIP_PUSH=true
touch "${FAKE_LOCAL_REPOSITORY}"/new-file-1.txt
export INPUT_BEFORE_PUSH_HOOK="echo SHOULD_NOT_RUN > '${FAKE_LOCAL_REPOSITORY}/before-push-marker.txt'"
export INPUT_AFTER_PUSH_HOOK="echo SHOULD_NOT_RUN > '${FAKE_LOCAL_REPOSITORY}/after-push-marker.txt'"
run git_auto_commit
assert_success
refute_line "::debug::Running before_push_hook"
refute_line "::debug::Running after_push_hook"
[ ! -f "${FAKE_LOCAL_REPOSITORY}/before-push-marker.txt" ]
[ ! -f "${FAKE_LOCAL_REPOSITORY}/after-push-marker.txt" ]
}
@test "A hook that exits non-zero aborts the action" {
touch "${FAKE_LOCAL_REPOSITORY}"/new-file-1.txt
export INPUT_BEFORE_COMMIT_HOOK="exit 1"
run git_auto_commit
assert_failure
assert_line "::debug::Running before_commit_hook"
# Assert the action aborted before committing: no commit_hash output was written.
run cat_github_output
refute_line -e "commit_hash=[0-9a-f]{40}$"
}
@test "A before_commit hook can mutate INPUT_COMMIT_MESSAGE and the change is reflected in the commit" {
touch "${FAKE_LOCAL_REPOSITORY}"/new-file-1.txt
export INPUT_BEFORE_COMMIT_HOOK="INPUT_COMMIT_MESSAGE='message rewritten by hook'"
run git_auto_commit
assert_success
assert_line "::debug::Running before_commit_hook"
run git -C "${FAKE_LOCAL_REPOSITORY}" log -1 --pretty=%B
assert_line "message rewritten by hook"
}