mirror of
https://github.com/stefanzweifel/git-auto-commit-action.git
synced 2026-07-26 20:24:37 +02:00
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:
@@ -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"
|
||||
# section below for context before disabling this warning.
|
||||
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.
|
||||
@@ -215,6 +228,87 @@ You can use these outputs to trigger other Actions in your Workflow run based on
|
||||
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
|
||||
|
||||
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.
|
||||
> 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.
|
||||
|
||||
|
||||
+32
@@ -84,6 +84,38 @@ inputs:
|
||||
description: Perform a clean git tag and push, without commiting anything
|
||||
required: 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:
|
||||
description: Suppress the security warning emitted when the action runs on a `pull_request_target` event.
|
||||
required: false
|
||||
|
||||
+42
-2
@@ -26,6 +26,16 @@ _log() {
|
||||
echo "::$level::$message";
|
||||
}
|
||||
|
||||
_run_hook() {
|
||||
local name=${1}
|
||||
local snippet=${2:-}
|
||||
|
||||
if [ -n "$snippet" ]; then
|
||||
_log "debug" "Running $name";
|
||||
eval "$snippet"
|
||||
fi
|
||||
}
|
||||
|
||||
_main() {
|
||||
_check_if_git_is_available
|
||||
|
||||
@@ -40,26 +50,56 @@ _main() {
|
||||
if "$INPUT_CREATE_GIT_TAG_ONLY"; then
|
||||
_log "debug" "Create git tag only";
|
||||
_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
|
||||
if ! "$INPUT_SKIP_PUSH"; then
|
||||
_run_hook "after_push_hook" "$INPUT_AFTER_PUSH_HOOK"
|
||||
fi
|
||||
elif _git_is_dirty || "$INPUT_SKIP_DIRTY_CHECK"; then
|
||||
|
||||
_set_github_output "changes_detected" "true"
|
||||
|
||||
_switch_to_branch
|
||||
|
||||
_run_hook "before_add_hook" "$INPUT_BEFORE_ADD_HOOK"
|
||||
_add_files
|
||||
_run_hook "after_add_hook" "$INPUT_AFTER_ADD_HOOK"
|
||||
|
||||
# Check dirty state of repo again using git-diff.
|
||||
# (git-diff detects better if CRLF of files changes and does NOT
|
||||
# proceed, if only CRLF changes are detected. See #241 and #265
|
||||
# for more details.)
|
||||
if [ -n "$(git diff --staged)" ] || "$INPUT_SKIP_DIRTY_CHECK"; then
|
||||
_run_hook "before_commit_hook" "$INPUT_BEFORE_COMMIT_HOOK"
|
||||
_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
|
||||
if ! "$INPUT_SKIP_PUSH"; then
|
||||
_run_hook "after_push_hook" "$INPUT_AFTER_PUSH_HOOK"
|
||||
fi
|
||||
else
|
||||
_set_github_output "changes_detected" "false"
|
||||
|
||||
|
||||
@@ -43,6 +43,14 @@ setup() {
|
||||
export INPUT_CREATE_BRANCH=false
|
||||
export INPUT_DISABLE_PULL_REQUEST_TARGET_TRIGGER_WARNING=false
|
||||
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
|
||||
# pull_request_target from the environment of the shell running BATS.
|
||||
@@ -1572,3 +1580,185 @@ END
|
||||
assert_success
|
||||
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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user