diff --git a/README.md b/README.md index 85611c5f..34371900 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/action.yml b/action.yml index 9b06340f..4d00735f 100644 --- a/action.yml +++ b/action.yml @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh index 320d43dc..3c8ddd06 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 - - _tag_commit - + _run_hook "after_commit_hook" "$INPUT_AFTER_COMMIT_HOOK" + + 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" diff --git a/tests/git-auto-commit.bats b/tests/git-auto-commit.bats index 01803107..6b0c3100 100644 --- a/tests/git-auto-commit.bats +++ b/tests/git-auto-commit.bats @@ -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" +}