From a29e2d6361b4d6d1b308d033eb52d74eb73ba070 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 15 Oct 2026 11:06:43 -0400 Subject: [PATCH 1/6] feat: enhance-dependabot --- .changeset/lucky-pumas-enhance.md | 5 + .github/workflows/enhance-dependabot.yml | 277 ++++++++++++++++++ workflows/enhance-dependabot/CHANGELOG.md | 1 + workflows/enhance-dependabot/README.md | 129 ++++++++ .../enhance-dependabot/enhance-dependabot.yml | 273 +++++++++++++++++ workflows/enhance-dependabot/package.json | 11 + 6 files changed, 696 insertions(+) create mode 100644 .changeset/lucky-pumas-enhance.md create mode 100644 .github/workflows/enhance-dependabot.yml create mode 100644 workflows/enhance-dependabot/CHANGELOG.md create mode 100644 workflows/enhance-dependabot/README.md create mode 100644 workflows/enhance-dependabot/enhance-dependabot.yml create mode 100644 workflows/enhance-dependabot/package.json diff --git a/.changeset/lucky-pumas-enhance.md b/.changeset/lucky-pumas-enhance.md new file mode 100644 index 000000000..14f4d01d5 --- /dev/null +++ b/.changeset/lucky-pumas-enhance.md @@ -0,0 +1,5 @@ +--- +"enhance-dependabot": major +--- + +initial versioned release of the enhance-dependabot reusable workflow, which makes Dependabot PRs actionable by running repository-specific setup, generation, and cleanup steps on the PR head branch and committing the result back to the same branch so regular CI re-runs on the enhanced code diff --git a/.github/workflows/enhance-dependabot.yml b/.github/workflows/enhance-dependabot.yml new file mode 100644 index 000000000..26331e57d --- /dev/null +++ b/.github/workflows/enhance-dependabot.yml @@ -0,0 +1,277 @@ +# GENERATED FILE - DO NOT EDIT DIRECTLY. +# Source: workflows/enhance-dependabot/enhance-dependabot.yml +# Edit the source under workflows/, then regenerate. + +name: enhance-dependabot + +on: + workflow_call: + secrets: + SLACK_TOKEN: + required: false + SLACK_CHANNEL_ID: + required: false + inputs: + setup-command: + required: false + type: string + description: | + Shell command(s) run before `enhance-command` to prepare the + environment: toolchain setup, dependency installation, registry + login, etc. Example: `make setup`. + enhance-command: + required: true + type: string + description: | + Idempotent shell command(s), run from the repository root, that + transform the repository into its final post-dependency-update + state. Example: `go mod tidy && make generate`, or a + `make enhance-dependabot` target. + commit-message: + required: false + type: string + default: "chore(dependabot): apply post-update generation and cleanup" + comment: + required: false + type: boolean + default: true + description: | + Comment on the pull request when changes are pushed, or when the + setup/enhance commands or the push fail. Quiet no-op otherwise. + timeout-minutes: + required: false + type: number + default: 30 + +permissions: { } + +jobs: + enhance: + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: write + pull-requests: write + env: + GH_TOKEN: ${{ github.token }} + HAS_SLACK: ${{ secrets.SLACK_TOKEN != '' && secrets.SLACK_CHANNEL_ID != '' }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_URL: ${{ github.event.pull_request.html_url }} + RUN_URL: ${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }} + steps: + # Defense-in-depth for the pull_request_target based trigger: refuse to + # run on anything other than an in-repository dependabot branch. + - name: Validate pull request + run: | + if [[ "$PR_HEAD_REPO" != "$GITHUB_REPOSITORY" ]]; then + echo "::error::Refusing to run: PR head repository '$PR_HEAD_REPO' is not '$GITHUB_REPOSITORY'." >&2 + exit 1 + fi + if [[ "$PR_HEAD_REF" != dependabot/* ]]; then + echo "::error::Refusing to run: PR head branch '$PR_HEAD_REF' is not a 'dependabot/*' branch." >&2 + exit 1 + fi + + - name: Checkout PR head + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: true + + - name: Run setup command + id: setup + if: ${{ inputs.setup-command != '' }} + env: + SETUP_COMMAND: ${{ inputs.setup-command }} + run: eval "$SETUP_COMMAND" + continue-on-error: true + + - name: Run enhance command + id: enhance + env: + ENHANCE_COMMAND: ${{ inputs.enhance-command }} + run: eval "$ENHANCE_COMMAND" + continue-on-error: true + + - name: Comment on command failure + if: ${{ inputs.comment && (steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure') }} + env: + SETUP_OUTCOME: ${{ steps.setup.outcome }} + ENHANCE_OUTCOME: ${{ steps.enhance.outcome }} + run: | + failed_step="enhance-command" + if [[ "$SETUP_OUTCOME" == 'failure' ]]; then + failed_step="setup-command" + fi + { + echo "### :warning: enhance-dependabot failed" + echo + echo "The \`${failed_step}\` step failed, so this PR could not be enhanced." + echo + echo "- [Failed run](${RUN_URL})" + echo + echo "If the failure is unrelated to this dependency update, re-run the workflow. If the update itself breaks your generation/cleanup steps, close this PR and let Dependabot try again later." + } | gh pr comment "$PR_NUMBER" --body-file - + + - name: Notify command failure on Slack + if: ${{ env.HAS_SLACK == 'true' && (steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure') }} + uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_TOKEN }} + payload: | + channel: ${{ secrets.SLACK_CHANNEL_ID}} + text: | + Failed to enhance Dependabot PR in `${{ github.event.repository.name }}`! :panic: - <${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }}|Run> + PR: <${{ github.event.pull_request.html_url }}|#${{ github.event.pull_request.number }}> + + - name: Exit on command failure + if: ${{ steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure' }} + run: exit 1 + + - name: Check for changes + id: changes + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "changed=true" | tee -a "$GITHUB_OUTPUT" + else + echo "changed=false" | tee -a "$GITHUB_OUTPUT" + fi + + - name: Commit changes + id: commit + if: ${{ steps.changes.outputs.changed == 'true' }} + env: + COMMIT_MESSAGE: ${{ inputs.commit-message }} + run: | + 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" + echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" + git diff --stat HEAD~1 HEAD | tee commit-stat.txt + + - name: Push commit + id: push + if: ${{ steps.commit.outcome == 'success' }} + run: git push origin "HEAD:${PR_HEAD_REF}" + continue-on-error: true + + # Dependabot (or someone) may have updated the branch while the enhance + # command was running, which makes the push a non-fast-forward. Replay + # the enhancement commit on top of the new head and push again. + - name: Retry push onto updated branch + id: retry + if: ${{ steps.push.outcome == 'failure' }} + run: | + commit="$(git rev-parse HEAD)" + git fetch --depth=1 origin "$PR_HEAD_REF" + git checkout --detach FETCH_HEAD + git cherry-pick "$commit" + git push origin "HEAD:${PR_HEAD_REF}" + continue-on-error: true + + - name: Comment on push failure + if: ${{ inputs.comment && steps.retry.outcome == 'failure' }} + env: + COMMIT_SHA: ${{ steps.commit.outputs.sha }} + run: | + { + echo "### :warning: enhance-dependabot could not push" + echo + echo "The enhancement commit \`${COMMIT_SHA}\` was created but could not be pushed to \`${PR_HEAD_REF}\`. The branch most likely moved (Dependabot rebase) and the replay onto the new head failed." + echo + echo "- [Failed run](${RUN_URL})" + echo + echo "Re-run the workflow from the run page, or comment \`@dependabot recreate\` to start over." + } | gh pr comment "$PR_NUMBER" --body-file - + + - name: Notify push failure on Slack + if: ${{ env.HAS_SLACK == 'true' && steps.retry.outcome == 'failure' }} + uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_TOKEN }} + payload: | + channel: ${{ secrets.SLACK_CHANNEL_ID}} + text: | + Failed to push enhancement commit to Dependabot PR in `${{ github.event.repository.name }}`! :panic: - <${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }}|Run> + PR: <${{ github.event.pull_request.html_url }}|#${{ github.event.pull_request.number }}> + + - name: Exit on push failure + if: ${{ steps.retry.outcome == 'failure' }} + run: exit 1 + + - name: Comment on success + if: ${{ inputs.comment && (steps.push.outcome == 'success' || steps.retry.outcome == 'success') }} + env: + COMMIT_SHA: ${{ steps.commit.outputs.sha }} + REPO: ${{ github.repository }} + run: | + { + echo "### :robot: enhance-dependabot" + echo + echo "Post-update generation/cleanup was applied and pushed to this branch. The repository's usual checks will run again against the updated head." + echo + echo "- Commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" + echo "- [Run](${RUN_URL})" + echo + echo "
Changed files" + echo + echo '```' + cat commit-stat.txt + echo '```' + echo + echo "
" + } | gh pr comment "$PR_NUMBER" --body-file - + + - name: Write job summary + if: ${{ always() }} + env: + SETUP_OUTCOME: ${{ steps.setup.outcome }} + ENHANCE_OUTCOME: ${{ steps.enhance.outcome }} + PUSH_OUTCOME: ${{ steps.push.outcome }} + RETRY_OUTCOME: ${{ steps.retry.outcome }} + CHANGED: ${{ steps.changes.outputs.changed }} + COMMIT_SHA: ${{ steps.commit.outputs.sha }} + REPO: ${{ github.repository }} + run: | + { + echo "## enhance-dependabot" + echo + echo "- PR: [#${PR_NUMBER}](${PR_URL})" + if [[ "$SETUP_OUTCOME" == 'success' || "$SETUP_OUTCOME" == 'failure' ]]; then + echo "- Setup command outcome: ${SETUP_OUTCOME}" + fi + echo "- Enhance command outcome: ${ENHANCE_OUTCOME:-skipped}" + if [[ "$CHANGED" == 'true' ]]; then + echo "- Repository changes detected: true" + echo "- Enhancement commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" + echo "- Push outcome: ${PUSH_OUTCOME:-skipped}" + if [[ "$RETRY_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'failure' ]]; then + echo "- Push retry outcome: ${RETRY_OUTCOME}" + fi + else + echo "- Repository changes detected: ${CHANGED:-unknown}" + fi + echo + if [[ "$ENHANCE_OUTCOME" == 'failure' || "$RETRY_OUTCOME" == 'failure' ]]; then + echo "The PR was NOT enhanced. See the PR comment for details." + elif [[ "$CHANGED" == 'true' && ( "$PUSH_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'success' ) ]]; then + echo "The PR was enhanced and the commit was pushed." + elif [[ "$CHANGED" == 'false' ]]; then + echo "Nothing to do: the enhance command produced no changes." + fi + if [[ -f commit-stat.txt ]]; then + echo + echo "
Changed files" + echo + echo '```' + cat commit-stat.txt + echo '```' + echo + echo "
" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/workflows/enhance-dependabot/CHANGELOG.md b/workflows/enhance-dependabot/CHANGELOG.md new file mode 100644 index 000000000..cc35532fe --- /dev/null +++ b/workflows/enhance-dependabot/CHANGELOG.md @@ -0,0 +1 @@ +# enhance-dependabot diff --git a/workflows/enhance-dependabot/README.md b/workflows/enhance-dependabot/README.md new file mode 100644 index 000000000..8c3038c09 --- /dev/null +++ b/workflows/enhance-dependabot/README.md @@ -0,0 +1,129 @@ +# enhance-dependabot + +Makes Dependabot pull requests actionable by running repository-specific setup, +generation, and cleanup steps (e.g. `go mod tidy`, code generation, formatting) +on the PR's head branch and committing the result back to the same branch. Your +repository's usual CI checks then re-run automatically against the enhanced +head, so the only thing left for a developer to do is review, approve, and +merge. + +## Recommended usage + +Add this workflow to your repository (e.g. +`.github/workflows/enhance-dependabot.yml`): + +```yaml +name: enhance-dependabot + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: {} + +jobs: + enhance: + if: github.actor == 'dependabot[bot]' + permissions: + contents: write # push the enhancement commit to the dependabot branch + pull-requests: write # comment on the PR + uses: smartcontractkit/.github/.github/workflows/enhance-dependabot.yml@enhance-dependabot/v1 + with: + setup-command: make setup # toolchain, dependencies, caches, logins + enhance-command: make enhance-dependabot # tidy, generate, format, etc. + secrets: inherit +``` + +**Important:** use `pull_request_target`, not `pull_request`. Workflows +triggered by Dependabot's `pull_request` events run with a read-only +`GITHUB_TOKEN` and no Actions secrets (they are treated like fork PRs), so they +cannot push. `pull_request_target` does not have this limitation, and it runs +the workflow file from your default branch, which is what the `actor` check +above gates on. + +### Recommended repository setup + +Expose a single idempotent target, e.g. `make enhance-dependabot`, that +transforms the repository into its final post-dependency-update state. This +keeps the trigger workflow identical across repositories, and gives developers +the exact same command to run when doing manual bumps. The `enhance-command` +must be idempotent: it is re-run whenever Dependabot rebases the PR. + +### Grouping updates (fewer PRs, fewer runs) + +Dependabot can group updates so you get one PR per ecosystem per interval (e.g. +all Go updates in a single weekly PR) instead of one PR per dependency. This +also means one enhancement run and one CI rerun per PR instead of N: + +```yaml +# .github/dependabot.yml +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + groups: + weekly-go-deps: + applies-to: version-updates + patterns: + - "*" +``` + +### Secrets + +If your setup or enhance commands need credentials (e.g. private module access), +pass them with `secrets: inherit`. They reach the reusable workflow because of +the `pull_request_target` trigger; they would not be available with a +`pull_request` trigger. + +Optionally pass `SLACK_TOKEN` and `SLACK_CHANNEL_ID` (see inputs below) to get +Slack notifications when enhancement fails. Without them, failures are still +reported as a red check and a PR comment. + +## Inputs + +| Input | Required | Default | Description | +| ----------------- | -------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `setup-command` | no | `''` | Shell command(s) run before `enhance-command` to prepare the environment (toolchains, dependencies, logins). | +| `enhance-command` | yes | - | Idempotent shell command(s), run from the repository root, that produce the final post-update state. | +| `commit-message` | no | `chore(dependabot): apply post-update generation and cleanup` | Commit message for the enhancement commit. | +| `comment` | no | `true` | Comment on the PR when changes are pushed or a step fails. Quiet no-op otherwise. | +| `timeout-minutes` | no | `30` | Job timeout. | + +## Secrets + +| Secret | Required | Description | +| ------------------ | -------- | ------------------------------------------------------- | +| `SLACK_TOKEN` | no | Slack bot token used to notify on enhancement failures. | +| `SLACK_CHANNEL_ID` | no | Slack channel ID to notify on enhancement failures. | + +## Behavior + +- Checks out the PR head (only `dependabot/*` branches of the same repository, + otherwise it refuses to run) and runs your commands. +- If the commands produce changes: commits them as `github-actions[bot]` and + pushes to the same Dependabot branch. The push triggers a `synchronize` event, + so your usual PR checks re-run against the enhanced head. +- If the branch moved while the commands were running (Dependabot rebase), + replays the enhancement commit on top of the new head and retries the push + once. +- If the commands produce no changes: succeeds quietly (green check, no + comment). +- If a command or the push fails: fails the job (red check), comments on the PR + with the run link, and optionally notifies Slack. + +Re-runs are loop-safe: the trigger is gated on `actor == 'dependabot[bot]'`, and +the enhancement push fires `synchronize` as `github-actions[bot]`, so the +workflow does not re-trigger itself. A Dependabot rebase re-triggers enhancement +automatically, keeping the PR enhanced after rebases. + +## Known tradeoffs + +- Once the enhancement commit is pushed, `@dependabot rebase` will refuse to + rebase the PR (the branch contains non-Dependabot commits). Use + `@dependabot recreate` instead: it recreates the PR from scratch and the + enhancement workflow re-runs on the new head. +- `secrets: inherit` hands all repository secrets to the pinned reusable + workflow version, the same trust model as other reusable workflows in this + repository. diff --git a/workflows/enhance-dependabot/enhance-dependabot.yml b/workflows/enhance-dependabot/enhance-dependabot.yml new file mode 100644 index 000000000..b3a3eace4 --- /dev/null +++ b/workflows/enhance-dependabot/enhance-dependabot.yml @@ -0,0 +1,273 @@ +name: enhance-dependabot + +on: + workflow_call: + secrets: + SLACK_TOKEN: + required: false + SLACK_CHANNEL_ID: + required: false + inputs: + setup-command: + required: false + type: string + description: | + Shell command(s) run before `enhance-command` to prepare the + environment: toolchain setup, dependency installation, registry + login, etc. Example: `make setup`. + enhance-command: + required: true + type: string + description: | + Idempotent shell command(s), run from the repository root, that + transform the repository into its final post-dependency-update + state. Example: `go mod tidy && make generate`, or a + `make enhance-dependabot` target. + commit-message: + required: false + type: string + default: "chore(dependabot): apply post-update generation and cleanup" + comment: + required: false + type: boolean + default: true + description: | + Comment on the pull request when changes are pushed, or when the + setup/enhance commands or the push fail. Quiet no-op otherwise. + timeout-minutes: + required: false + type: number + default: 30 + +permissions: { } + +jobs: + enhance: + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: write + pull-requests: write + env: + GH_TOKEN: ${{ github.token }} + HAS_SLACK: ${{ secrets.SLACK_TOKEN != '' && secrets.SLACK_CHANNEL_ID != '' }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_URL: ${{ github.event.pull_request.html_url }} + RUN_URL: ${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }} + steps: + # Defense-in-depth for the pull_request_target based trigger: refuse to + # run on anything other than an in-repository dependabot branch. + - name: Validate pull request + run: | + if [[ "$PR_HEAD_REPO" != "$GITHUB_REPOSITORY" ]]; then + echo "::error::Refusing to run: PR head repository '$PR_HEAD_REPO' is not '$GITHUB_REPOSITORY'." >&2 + exit 1 + fi + if [[ "$PR_HEAD_REF" != dependabot/* ]]; then + echo "::error::Refusing to run: PR head branch '$PR_HEAD_REF' is not a 'dependabot/*' branch." >&2 + exit 1 + fi + + - name: Checkout PR head + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: true + + - name: Run setup command + id: setup + if: ${{ inputs.setup-command != '' }} + env: + SETUP_COMMAND: ${{ inputs.setup-command }} + run: eval "$SETUP_COMMAND" + continue-on-error: true + + - name: Run enhance command + id: enhance + env: + ENHANCE_COMMAND: ${{ inputs.enhance-command }} + run: eval "$ENHANCE_COMMAND" + continue-on-error: true + + - name: Comment on command failure + if: ${{ inputs.comment && (steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure') }} + env: + SETUP_OUTCOME: ${{ steps.setup.outcome }} + ENHANCE_OUTCOME: ${{ steps.enhance.outcome }} + run: | + failed_step="enhance-command" + if [[ "$SETUP_OUTCOME" == 'failure' ]]; then + failed_step="setup-command" + fi + { + echo "### :warning: enhance-dependabot failed" + echo + echo "The \`${failed_step}\` step failed, so this PR could not be enhanced." + echo + echo "- [Failed run](${RUN_URL})" + echo + echo "If the failure is unrelated to this dependency update, re-run the workflow. If the update itself breaks your generation/cleanup steps, close this PR and let Dependabot try again later." + } | gh pr comment "$PR_NUMBER" --body-file - + + - name: Notify command failure on Slack + if: ${{ env.HAS_SLACK == 'true' && (steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure') }} + uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_TOKEN }} + payload: | + channel: ${{ secrets.SLACK_CHANNEL_ID}} + text: | + Failed to enhance Dependabot PR in `${{ github.event.repository.name }}`! :panic: - <${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }}|Run> + PR: <${{ github.event.pull_request.html_url }}|#${{ github.event.pull_request.number }}> + + - name: Exit on command failure + if: ${{ steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure' }} + run: exit 1 + + - name: Check for changes + id: changes + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "changed=true" | tee -a "$GITHUB_OUTPUT" + else + echo "changed=false" | tee -a "$GITHUB_OUTPUT" + fi + + - name: Commit changes + id: commit + if: ${{ steps.changes.outputs.changed == 'true' }} + env: + COMMIT_MESSAGE: ${{ inputs.commit-message }} + run: | + 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" + echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" + git diff --stat HEAD~1 HEAD | tee commit-stat.txt + + - name: Push commit + id: push + if: ${{ steps.commit.outcome == 'success' }} + run: git push origin "HEAD:${PR_HEAD_REF}" + continue-on-error: true + + # Dependabot (or someone) may have updated the branch while the enhance + # command was running, which makes the push a non-fast-forward. Replay + # the enhancement commit on top of the new head and push again. + - name: Retry push onto updated branch + id: retry + if: ${{ steps.push.outcome == 'failure' }} + run: | + commit="$(git rev-parse HEAD)" + git fetch --depth=1 origin "$PR_HEAD_REF" + git checkout --detach FETCH_HEAD + git cherry-pick "$commit" + git push origin "HEAD:${PR_HEAD_REF}" + continue-on-error: true + + - name: Comment on push failure + if: ${{ inputs.comment && steps.retry.outcome == 'failure' }} + env: + COMMIT_SHA: ${{ steps.commit.outputs.sha }} + run: | + { + echo "### :warning: enhance-dependabot could not push" + echo + echo "The enhancement commit \`${COMMIT_SHA}\` was created but could not be pushed to \`${PR_HEAD_REF}\`. The branch most likely moved (Dependabot rebase) and the replay onto the new head failed." + echo + echo "- [Failed run](${RUN_URL})" + echo + echo "Re-run the workflow from the run page, or comment \`@dependabot recreate\` to start over." + } | gh pr comment "$PR_NUMBER" --body-file - + + - name: Notify push failure on Slack + if: ${{ env.HAS_SLACK == 'true' && steps.retry.outcome == 'failure' }} + uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_TOKEN }} + payload: | + channel: ${{ secrets.SLACK_CHANNEL_ID}} + text: | + Failed to push enhancement commit to Dependabot PR in `${{ github.event.repository.name }}`! :panic: - <${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }}|Run> + PR: <${{ github.event.pull_request.html_url }}|#${{ github.event.pull_request.number }}> + + - name: Exit on push failure + if: ${{ steps.retry.outcome == 'failure' }} + run: exit 1 + + - name: Comment on success + if: ${{ inputs.comment && (steps.push.outcome == 'success' || steps.retry.outcome == 'success') }} + env: + COMMIT_SHA: ${{ steps.commit.outputs.sha }} + REPO: ${{ github.repository }} + run: | + { + echo "### :robot: enhance-dependabot" + echo + echo "Post-update generation/cleanup was applied and pushed to this branch. The repository's usual checks will run again against the updated head." + echo + echo "- Commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" + echo "- [Run](${RUN_URL})" + echo + echo "
Changed files" + echo + echo '```' + cat commit-stat.txt + echo '```' + echo + echo "
" + } | gh pr comment "$PR_NUMBER" --body-file - + + - name: Write job summary + if: ${{ always() }} + env: + SETUP_OUTCOME: ${{ steps.setup.outcome }} + ENHANCE_OUTCOME: ${{ steps.enhance.outcome }} + PUSH_OUTCOME: ${{ steps.push.outcome }} + RETRY_OUTCOME: ${{ steps.retry.outcome }} + CHANGED: ${{ steps.changes.outputs.changed }} + COMMIT_SHA: ${{ steps.commit.outputs.sha }} + REPO: ${{ github.repository }} + run: | + { + echo "## enhance-dependabot" + echo + echo "- PR: [#${PR_NUMBER}](${PR_URL})" + if [[ "$SETUP_OUTCOME" == 'success' || "$SETUP_OUTCOME" == 'failure' ]]; then + echo "- Setup command outcome: ${SETUP_OUTCOME}" + fi + echo "- Enhance command outcome: ${ENHANCE_OUTCOME:-skipped}" + if [[ "$CHANGED" == 'true' ]]; then + echo "- Repository changes detected: true" + echo "- Enhancement commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" + echo "- Push outcome: ${PUSH_OUTCOME:-skipped}" + if [[ "$RETRY_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'failure' ]]; then + echo "- Push retry outcome: ${RETRY_OUTCOME}" + fi + else + echo "- Repository changes detected: ${CHANGED:-unknown}" + fi + echo + if [[ "$ENHANCE_OUTCOME" == 'failure' || "$RETRY_OUTCOME" == 'failure' ]]; then + echo "The PR was NOT enhanced. See the PR comment for details." + elif [[ "$CHANGED" == 'true' && ( "$PUSH_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'success' ) ]]; then + echo "The PR was enhanced and the commit was pushed." + elif [[ "$CHANGED" == 'false' ]]; then + echo "Nothing to do: the enhance command produced no changes." + fi + if [[ -f commit-stat.txt ]]; then + echo + echo "
Changed files" + echo + echo '```' + cat commit-stat.txt + echo '```' + echo + echo "
" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/workflows/enhance-dependabot/package.json b/workflows/enhance-dependabot/package.json new file mode 100644 index 000000000..9065157a1 --- /dev/null +++ b/workflows/enhance-dependabot/package.json @@ -0,0 +1,11 @@ +{ + "name": "enhance-dependabot", + "version": "0.0.0", + "description": "", + "private": true, + "scripts": {}, + "keywords": [], + "author": "@smartcontractkit", + "license": "MIT", + "packageManager": "pnpm@10.29.3" +} From 216a905fd17a9ccbee684accc5cadb761cd7942c Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 15 Oct 2026 11:35:32 -0400 Subject: [PATCH 2/6] fix(enhance-dependabot): move setup to consumer --- .changeset/lucky-pumas-enhance.md | 2 +- .github/workflows/enhance-dependabot.yml | 277 ----------------- .../enhance-dependabot/CHANGELOG.md | 0 actions/enhance-dependabot/README.md | 137 +++++++++ actions/enhance-dependabot/action.yml | 286 ++++++++++++++++++ .../enhance-dependabot/package.json | 0 pnpm-lock.yaml | 2 + workflows/enhance-dependabot/README.md | 129 -------- .../enhance-dependabot/enhance-dependabot.yml | 273 ----------------- 9 files changed, 426 insertions(+), 680 deletions(-) delete mode 100644 .github/workflows/enhance-dependabot.yml rename {workflows => actions}/enhance-dependabot/CHANGELOG.md (100%) create mode 100644 actions/enhance-dependabot/README.md create mode 100644 actions/enhance-dependabot/action.yml rename {workflows => actions}/enhance-dependabot/package.json (100%) delete mode 100644 workflows/enhance-dependabot/README.md delete mode 100644 workflows/enhance-dependabot/enhance-dependabot.yml diff --git a/.changeset/lucky-pumas-enhance.md b/.changeset/lucky-pumas-enhance.md index 14f4d01d5..dc30a6469 100644 --- a/.changeset/lucky-pumas-enhance.md +++ b/.changeset/lucky-pumas-enhance.md @@ -2,4 +2,4 @@ "enhance-dependabot": major --- -initial versioned release of the enhance-dependabot reusable workflow, which makes Dependabot PRs actionable by running repository-specific setup, generation, and cleanup steps on the PR head branch and committing the result back to the same branch so regular CI re-runs on the enhanced code +initial versioned release of the enhance-dependabot action, which makes Dependabot PRs actionable by running a repository-defined enhancement command on the checked-out PR head, committing the result, and pushing it back to the same Dependabot branch so regular CI re-runs on the enhanced code diff --git a/.github/workflows/enhance-dependabot.yml b/.github/workflows/enhance-dependabot.yml deleted file mode 100644 index 26331e57d..000000000 --- a/.github/workflows/enhance-dependabot.yml +++ /dev/null @@ -1,277 +0,0 @@ -# GENERATED FILE - DO NOT EDIT DIRECTLY. -# Source: workflows/enhance-dependabot/enhance-dependabot.yml -# Edit the source under workflows/, then regenerate. - -name: enhance-dependabot - -on: - workflow_call: - secrets: - SLACK_TOKEN: - required: false - SLACK_CHANNEL_ID: - required: false - inputs: - setup-command: - required: false - type: string - description: | - Shell command(s) run before `enhance-command` to prepare the - environment: toolchain setup, dependency installation, registry - login, etc. Example: `make setup`. - enhance-command: - required: true - type: string - description: | - Idempotent shell command(s), run from the repository root, that - transform the repository into its final post-dependency-update - state. Example: `go mod tidy && make generate`, or a - `make enhance-dependabot` target. - commit-message: - required: false - type: string - default: "chore(dependabot): apply post-update generation and cleanup" - comment: - required: false - type: boolean - default: true - description: | - Comment on the pull request when changes are pushed, or when the - setup/enhance commands or the push fail. Quiet no-op otherwise. - timeout-minutes: - required: false - type: number - default: 30 - -permissions: { } - -jobs: - enhance: - runs-on: ubuntu-latest - timeout-minutes: ${{ inputs.timeout-minutes }} - permissions: - contents: write - pull-requests: write - env: - GH_TOKEN: ${{ github.token }} - HAS_SLACK: ${{ secrets.SLACK_TOKEN != '' && secrets.SLACK_CHANNEL_ID != '' }} - PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} - PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_URL: ${{ github.event.pull_request.html_url }} - RUN_URL: ${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }} - steps: - # Defense-in-depth for the pull_request_target based trigger: refuse to - # run on anything other than an in-repository dependabot branch. - - name: Validate pull request - run: | - if [[ "$PR_HEAD_REPO" != "$GITHUB_REPOSITORY" ]]; then - echo "::error::Refusing to run: PR head repository '$PR_HEAD_REPO' is not '$GITHUB_REPOSITORY'." >&2 - exit 1 - fi - if [[ "$PR_HEAD_REF" != dependabot/* ]]; then - echo "::error::Refusing to run: PR head branch '$PR_HEAD_REF' is not a 'dependabot/*' branch." >&2 - exit 1 - fi - - - name: Checkout PR head - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: true - - - name: Run setup command - id: setup - if: ${{ inputs.setup-command != '' }} - env: - SETUP_COMMAND: ${{ inputs.setup-command }} - run: eval "$SETUP_COMMAND" - continue-on-error: true - - - name: Run enhance command - id: enhance - env: - ENHANCE_COMMAND: ${{ inputs.enhance-command }} - run: eval "$ENHANCE_COMMAND" - continue-on-error: true - - - name: Comment on command failure - if: ${{ inputs.comment && (steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure') }} - env: - SETUP_OUTCOME: ${{ steps.setup.outcome }} - ENHANCE_OUTCOME: ${{ steps.enhance.outcome }} - run: | - failed_step="enhance-command" - if [[ "$SETUP_OUTCOME" == 'failure' ]]; then - failed_step="setup-command" - fi - { - echo "### :warning: enhance-dependabot failed" - echo - echo "The \`${failed_step}\` step failed, so this PR could not be enhanced." - echo - echo "- [Failed run](${RUN_URL})" - echo - echo "If the failure is unrelated to this dependency update, re-run the workflow. If the update itself breaks your generation/cleanup steps, close this PR and let Dependabot try again later." - } | gh pr comment "$PR_NUMBER" --body-file - - - - name: Notify command failure on Slack - if: ${{ env.HAS_SLACK == 'true' && (steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure') }} - uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_TOKEN }} - payload: | - channel: ${{ secrets.SLACK_CHANNEL_ID}} - text: | - Failed to enhance Dependabot PR in `${{ github.event.repository.name }}`! :panic: - <${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }}|Run> - PR: <${{ github.event.pull_request.html_url }}|#${{ github.event.pull_request.number }}> - - - name: Exit on command failure - if: ${{ steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure' }} - run: exit 1 - - - name: Check for changes - id: changes - run: | - if [[ -n "$(git status --porcelain)" ]]; then - echo "changed=true" | tee -a "$GITHUB_OUTPUT" - else - echo "changed=false" | tee -a "$GITHUB_OUTPUT" - fi - - - name: Commit changes - id: commit - if: ${{ steps.changes.outputs.changed == 'true' }} - env: - COMMIT_MESSAGE: ${{ inputs.commit-message }} - run: | - 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" - echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" - git diff --stat HEAD~1 HEAD | tee commit-stat.txt - - - name: Push commit - id: push - if: ${{ steps.commit.outcome == 'success' }} - run: git push origin "HEAD:${PR_HEAD_REF}" - continue-on-error: true - - # Dependabot (or someone) may have updated the branch while the enhance - # command was running, which makes the push a non-fast-forward. Replay - # the enhancement commit on top of the new head and push again. - - name: Retry push onto updated branch - id: retry - if: ${{ steps.push.outcome == 'failure' }} - run: | - commit="$(git rev-parse HEAD)" - git fetch --depth=1 origin "$PR_HEAD_REF" - git checkout --detach FETCH_HEAD - git cherry-pick "$commit" - git push origin "HEAD:${PR_HEAD_REF}" - continue-on-error: true - - - name: Comment on push failure - if: ${{ inputs.comment && steps.retry.outcome == 'failure' }} - env: - COMMIT_SHA: ${{ steps.commit.outputs.sha }} - run: | - { - echo "### :warning: enhance-dependabot could not push" - echo - echo "The enhancement commit \`${COMMIT_SHA}\` was created but could not be pushed to \`${PR_HEAD_REF}\`. The branch most likely moved (Dependabot rebase) and the replay onto the new head failed." - echo - echo "- [Failed run](${RUN_URL})" - echo - echo "Re-run the workflow from the run page, or comment \`@dependabot recreate\` to start over." - } | gh pr comment "$PR_NUMBER" --body-file - - - - name: Notify push failure on Slack - if: ${{ env.HAS_SLACK == 'true' && steps.retry.outcome == 'failure' }} - uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_TOKEN }} - payload: | - channel: ${{ secrets.SLACK_CHANNEL_ID}} - text: | - Failed to push enhancement commit to Dependabot PR in `${{ github.event.repository.name }}`! :panic: - <${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }}|Run> - PR: <${{ github.event.pull_request.html_url }}|#${{ github.event.pull_request.number }}> - - - name: Exit on push failure - if: ${{ steps.retry.outcome == 'failure' }} - run: exit 1 - - - name: Comment on success - if: ${{ inputs.comment && (steps.push.outcome == 'success' || steps.retry.outcome == 'success') }} - env: - COMMIT_SHA: ${{ steps.commit.outputs.sha }} - REPO: ${{ github.repository }} - run: | - { - echo "### :robot: enhance-dependabot" - echo - echo "Post-update generation/cleanup was applied and pushed to this branch. The repository's usual checks will run again against the updated head." - echo - echo "- Commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" - echo "- [Run](${RUN_URL})" - echo - echo "
Changed files" - echo - echo '```' - cat commit-stat.txt - echo '```' - echo - echo "
" - } | gh pr comment "$PR_NUMBER" --body-file - - - - name: Write job summary - if: ${{ always() }} - env: - SETUP_OUTCOME: ${{ steps.setup.outcome }} - ENHANCE_OUTCOME: ${{ steps.enhance.outcome }} - PUSH_OUTCOME: ${{ steps.push.outcome }} - RETRY_OUTCOME: ${{ steps.retry.outcome }} - CHANGED: ${{ steps.changes.outputs.changed }} - COMMIT_SHA: ${{ steps.commit.outputs.sha }} - REPO: ${{ github.repository }} - run: | - { - echo "## enhance-dependabot" - echo - echo "- PR: [#${PR_NUMBER}](${PR_URL})" - if [[ "$SETUP_OUTCOME" == 'success' || "$SETUP_OUTCOME" == 'failure' ]]; then - echo "- Setup command outcome: ${SETUP_OUTCOME}" - fi - echo "- Enhance command outcome: ${ENHANCE_OUTCOME:-skipped}" - if [[ "$CHANGED" == 'true' ]]; then - echo "- Repository changes detected: true" - echo "- Enhancement commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" - echo "- Push outcome: ${PUSH_OUTCOME:-skipped}" - if [[ "$RETRY_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'failure' ]]; then - echo "- Push retry outcome: ${RETRY_OUTCOME}" - fi - else - echo "- Repository changes detected: ${CHANGED:-unknown}" - fi - echo - if [[ "$ENHANCE_OUTCOME" == 'failure' || "$RETRY_OUTCOME" == 'failure' ]]; then - echo "The PR was NOT enhanced. See the PR comment for details." - elif [[ "$CHANGED" == 'true' && ( "$PUSH_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'success' ) ]]; then - echo "The PR was enhanced and the commit was pushed." - elif [[ "$CHANGED" == 'false' ]]; then - echo "Nothing to do: the enhance command produced no changes." - fi - if [[ -f commit-stat.txt ]]; then - echo - echo "
Changed files" - echo - echo '```' - cat commit-stat.txt - echo '```' - echo - echo "
" - fi - } >> "$GITHUB_STEP_SUMMARY" diff --git a/workflows/enhance-dependabot/CHANGELOG.md b/actions/enhance-dependabot/CHANGELOG.md similarity index 100% rename from workflows/enhance-dependabot/CHANGELOG.md rename to actions/enhance-dependabot/CHANGELOG.md diff --git a/actions/enhance-dependabot/README.md b/actions/enhance-dependabot/README.md new file mode 100644 index 000000000..88da9cb84 --- /dev/null +++ b/actions/enhance-dependabot/README.md @@ -0,0 +1,137 @@ +# enhance-dependabot + +Makes Dependabot pull requests actionable. Your workflow checks out the PR head +and runs any setup steps it needs (toolchains, dependencies, logins); this +action then runs your `enhance-command` (e.g. `go mod tidy`, code generation, +formatting), commits the result, and pushes it back to the **same Dependabot +branch**. The push triggers a `synchronize` event, so your repository's usual PR +checks re-run automatically against the enhanced code — leaving developers with +a PR they only need to review, approve, and merge. + +## Usage + +Add a workflow to your repository (e.g. +`.github/workflows/enhance-dependabot.yml`): + +```yaml +name: enhance-dependabot + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +permissions: {} + +jobs: + enhance: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write # push the enhancement commit to the dependabot branch + pull-requests: write # comment on the PR + steps: + # Check out the Dependabot PR head, NOT the default merge/base ref. + # The action refuses to run if HEAD is not the PR head SHA. + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: true + + # Any setup steps your enhance command needs — you have full control: + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + # - uses: actions/setup-node@v6 + # with: + # node-version-file: .tool-versions + # - run: pnpm install --frozen-lockfile + # - uses: ...anything else... + + - uses: smartcontractkit/.github/actions/enhance-dependabot@enhance-dependabot/v1 + with: + enhance-command: go mod tidy && make generate + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # if codegen needs private packages + # SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} # optional failure notifications + # SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} +``` + +**Important:** + +- Use `pull_request_target`, not `pull_request`. Workflows triggered by + Dependabot's `pull_request` events run with a read-only `GITHUB_TOKEN` and no + Actions secrets (they are treated like fork PRs), so they cannot push. + `pull_request_target` does not have this limitation, and it runs the workflow + file from your default branch, which is what the `actor` check above gates on. +- Check out `github.event.pull_request.head.sha` with + `persist-credentials: true`. The action refuses to run if HEAD is not the PR + head SHA, since pushing a merge/base ref would carry a merge commit onto the + Dependabot branch. +- The `enhance-command` must be idempotent: it is re-run whenever Dependabot + rebases the PR. + +### Recommended repository setup + +Expose a single idempotent target, e.g. `make enhance-dependabot`, that +transforms the repository into its final post-dependency-update state. This +gives developers the exact same command to run when doing manual bumps. + +### Grouping updates (fewer PRs, fewer runs) + +Dependabot can group updates so you get one PR per ecosystem per interval (e.g. +all Go updates in a single weekly PR) instead of one PR per dependency. This +also means one enhancement run and one CI rerun per PR instead of N: + +```yaml +# .github/dependabot.yml +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + groups: + weekly-go-deps: + applies-to: version-updates + patterns: + - "*" +``` + +## Inputs + +| Input | Required | Default | Description | +| ------------------ | -------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `enhance-command` | yes | - | Idempotent shell command(s), run from the repository root, that produce the final post-update state. | +| `commit-message` | no | `chore(dependabot): apply post-update generation and cleanup` | Commit message for the enhancement commit. | +| `comment` | no | `true` | Comment on the PR when changes are pushed or a step fails. Quiet no-op otherwise. | +| `github-token` | no | `github.token` | GitHub token used to comment on the pull request. | +| `slack-token` | no | `''` | Slack bot token used to notify on enhancement failures. Notifications are skipped when unset. | +| `slack-channel-id` | no | `''` | Slack channel ID to notify on enhancement failures. | + +## Behavior + +- Guards: only runs on in-repository `dependabot/*` branches whose head SHA is + checked out, otherwise it fails without touching anything. +- Runs `enhance-command` in your workflow's environment (after your setup + steps). +- If it produces changes: commits them as `github-actions[bot]` and pushes to + the same Dependabot branch. The push triggers a `synchronize` event, so your + usual PR checks re-run against the enhanced head. +- If the branch moved while the command was running (Dependabot rebase), replays + the enhancement commit on top of the new head and retries the push once. +- If it produces no changes: succeeds quietly (green check, no comment). +- If the command or the push fails: fails the job (red check), comments on the + PR with the run link, and optionally notifies Slack. + +Re-runs are loop-safe: the trigger is gated on `actor == 'dependabot[bot]'`, and +the enhancement push fires `synchronize` as `github-actions[bot]`, so the +workflow does not re-trigger itself. A Dependabot rebase re-triggers enhancement +automatically, keeping the PR enhanced after rebases. + +## Known tradeoffs + +- Once the enhancement commit is pushed, `@dependabot rebase` will refuse to + rebase the PR (the branch contains non-Dependabot commits). Use + `@dependabot recreate` instead: it recreates the PR from scratch and the + enhancement workflow re-runs on the new head. diff --git a/actions/enhance-dependabot/action.yml b/actions/enhance-dependabot/action.yml new file mode 100644 index 000000000..b0bcbf007 --- /dev/null +++ b/actions/enhance-dependabot/action.yml @@ -0,0 +1,286 @@ +name: enhance-dependabot +description: | + Runs a repository-defined enhancement command on a Dependabot PR's checked-out + head, commits the result, and pushes it back to the same Dependabot branch so + the repository's usual checks re-run on the enhanced code. Setup (toolchains, + dependencies, logins) is done by the calling workflow before this action. + +inputs: + enhance-command: + required: true + description: | + Idempotent shell command(s), run from the repository root, that transform + the repository into its final post-dependency-update state. Example: + `go mod tidy && make generate`, or a `make enhance-dependabot` target. + commit-message: + required: false + default: "chore(dependabot): apply post-update generation and cleanup" + description: Commit message for the enhancement commit. + comment: + required: false + default: "true" + description: | + Comment on the pull request when changes are pushed, or when the enhance + command or the push fail. Quiet no-op otherwise. + github-token: + required: false + default: ${{ github.token }} + description: GitHub token used to comment on the pull request. + slack-token: + required: false + default: "" + description: | + Optional Slack bot token used to notify on enhancement failures. Slack + notifications are skipped when unset. + slack-channel-id: + required: false + default: "" + description: Optional Slack channel ID to notify on enhancement failures. + +runs: + using: composite + steps: + # Defense-in-depth for pull_request_target based triggers: refuse to run on + # anything other than an in-repository dependabot branch, and refuse to run + # unless HEAD is the PR head (pushing a merge/base ref would corrupt the + # Dependabot branch). + - name: Validate pull request + shell: bash + env: + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if [[ "$PR_HEAD_REPO" != "$GITHUB_REPOSITORY" ]]; then + echo "::error::Refusing to run: PR head repository '$PR_HEAD_REPO' is not '$GITHUB_REPOSITORY'." >&2 + exit 1 + fi + if [[ "$PR_HEAD_REF" != dependabot/* ]]; then + echo "::error::Refusing to run: PR head branch '$PR_HEAD_REF' is not a 'dependabot/*' branch." >&2 + exit 1 + fi + if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "::error::No git checkout found. Check out the PR head (github.event.pull_request.head.sha) before running this action." >&2 + exit 1 + fi + head="$(git rev-parse HEAD)" + if [[ "$head" != "$PR_HEAD_SHA" ]]; then + echo "::error::HEAD ($head) is not the PR head SHA ($PR_HEAD_SHA). Check out 'github.event.pull_request.head.sha' with 'persist-credentials: true' before running this action; otherwise the push would carry a merge commit onto the Dependabot branch." >&2 + exit 1 + fi + + - name: Run enhance command + id: enhance + shell: bash + env: + ENHANCE_COMMAND: ${{ inputs.enhance-command }} + run: eval "$ENHANCE_COMMAND" + continue-on-error: true + + - name: Comment on command failure + if: ${{ inputs.comment == 'true' && steps.enhance.outcome == 'failure' }} + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_URL: + ${{ format('https://github.com/{0}/actions/runs/{1}', + github.repository, github.run_id) }} + run: | + { + echo "### :warning: enhance-dependabot failed" + echo + echo "The \`enhance-command\` step failed, so this PR could not be enhanced." + echo + echo "- [Failed run](${RUN_URL})" + echo + echo "If the failure is unrelated to this dependency update, re-run the workflow. If the update itself breaks your generation/cleanup steps, close this PR and let Dependabot try again later." + } | gh pr comment "$PR_NUMBER" --body-file - + + - name: Notify command failure on Slack + if: + ${{ inputs.slack-token != '' && inputs.slack-channel-id != '' && + steps.enhance.outcome == 'failure' }} + uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 + with: + method: chat.postMessage + token: ${{ inputs.slack-token }} + payload: | + channel: ${{ inputs.slack-channel-id }} + text: | + Failed to enhance Dependabot PR in `${{ github.event.repository.name }}`! :panic: - <${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }}|Run> + PR: <${{ github.event.pull_request.html_url }}|#${{ github.event.pull_request.number }}> + + - name: Exit on command failure + if: ${{ steps.enhance.outcome == 'failure' }} + shell: bash + run: exit 1 + + - name: Check for changes + id: changes + shell: bash + run: | + if [[ -n "$(git status --porcelain)" ]]; then + echo "changed=true" | tee -a "$GITHUB_OUTPUT" + else + echo "changed=false" | tee -a "$GITHUB_OUTPUT" + fi + + - name: Commit changes + id: commit + if: ${{ steps.changes.outputs.changed == 'true' }} + shell: bash + env: + COMMIT_MESSAGE: ${{ inputs.commit-message }} + run: | + 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" + echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" + git diff --stat HEAD~1 HEAD | tee commit-stat.txt + + - name: Push commit + id: push + if: ${{ steps.commit.outcome == 'success' }} + shell: bash + env: + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: git push origin "HEAD:${PR_HEAD_REF}" + continue-on-error: true + + # Dependabot (or someone) may have updated the branch while the enhance + # command was running, which makes the push a non-fast-forward. Replay the + # enhancement commit on top of the new head and push again. + - name: Retry push onto updated branch + id: retry + if: ${{ steps.push.outcome == 'failure' }} + shell: bash + env: + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + commit="$(git rev-parse HEAD)" + git fetch --depth=1 origin "$PR_HEAD_REF" + git checkout --detach FETCH_HEAD + git cherry-pick "$commit" + git push origin "HEAD:${PR_HEAD_REF}" + continue-on-error: true + + - name: Comment on push failure + if: ${{ inputs.comment == 'true' && steps.retry.outcome == 'failure' }} + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + COMMIT_SHA: ${{ steps.commit.outputs.sha }} + RUN_URL: + ${{ format('https://github.com/{0}/actions/runs/{1}', + github.repository, github.run_id) }} + run: | + { + echo "### :warning: enhance-dependabot could not push" + echo + echo "The enhancement commit \`${COMMIT_SHA}\` was created but could not be pushed to \`${PR_HEAD_REF}\`. The branch most likely moved (Dependabot rebase) and the replay onto the new head failed." + echo + echo "- [Failed run](${RUN_URL})" + echo + echo "Re-run the workflow from the run page, or comment \`@dependabot recreate\` to start over." + } | gh pr comment "$PR_NUMBER" --body-file - + + - name: Notify push failure on Slack + if: + ${{ inputs.slack-token != '' && inputs.slack-channel-id != '' && + steps.retry.outcome == 'failure' }} + uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 + with: + method: chat.postMessage + token: ${{ inputs.slack-token }} + payload: | + channel: ${{ inputs.slack-channel-id }} + text: | + Failed to push enhancement commit to Dependabot PR in `${{ github.event.repository.name }}`! :panic: - <${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }}|Run> + PR: <${{ github.event.pull_request.html_url }}|#${{ github.event.pull_request.number }}> + + - name: Exit on push failure + if: ${{ steps.retry.outcome == 'failure' }} + shell: bash + run: exit 1 + + - name: Comment on success + if: + ${{ inputs.comment == 'true' && (steps.push.outcome == 'success' || + steps.retry.outcome == 'success') }} + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + COMMIT_SHA: ${{ steps.commit.outputs.sha }} + REPO: ${{ github.repository }} + RUN_URL: + ${{ format('https://github.com/{0}/actions/runs/{1}', + github.repository, github.run_id) }} + run: | + { + echo "### :robot: enhance-dependabot" + echo + echo "Post-update generation/cleanup was applied and pushed to this branch. The repository's usual checks will run again against the updated head." + echo + echo "- Commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" + echo "- [Run](${RUN_URL})" + echo + echo "
Changed files" + echo + echo '```' + cat commit-stat.txt + echo '```' + echo + echo "
" + } | gh pr comment "$PR_NUMBER" --body-file - + + - name: Write job summary + if: ${{ always() }} + shell: bash + env: + ENHANCE_OUTCOME: ${{ steps.enhance.outcome }} + PUSH_OUTCOME: ${{ steps.push.outcome }} + RETRY_OUTCOME: ${{ steps.retry.outcome }} + CHANGED: ${{ steps.changes.outputs.changed }} + COMMIT_SHA: ${{ steps.commit.outputs.sha }} + REPO: ${{ github.repository }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: | + { + echo "## enhance-dependabot" + echo + echo "- PR: ${PR_URL}" + echo "- Enhance command outcome: ${ENHANCE_OUTCOME:-skipped}" + if [[ "$CHANGED" == 'true' ]]; then + echo "- Repository changes detected: true" + echo "- Enhancement commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" + echo "- Push outcome: ${PUSH_OUTCOME:-skipped}" + if [[ "$RETRY_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'failure' ]]; then + echo "- Push retry outcome: ${RETRY_OUTCOME}" + fi + else + echo "- Repository changes detected: ${CHANGED:-unknown}" + fi + echo + if [[ "$ENHANCE_OUTCOME" == 'failure' || "$RETRY_OUTCOME" == 'failure' ]]; then + echo "The PR was NOT enhanced. See the PR comment for details." + elif [[ "$CHANGED" == 'true' && ( "$PUSH_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'success' ) ]]; then + echo "The PR was enhanced and the commit was pushed." + elif [[ "$CHANGED" == 'false' ]]; then + echo "Nothing to do: the enhance command produced no changes." + fi + if [[ -f commit-stat.txt ]]; then + echo + echo "
Changed files" + echo + echo '```' + cat commit-stat.txt + echo '```' + echo + echo "
" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/workflows/enhance-dependabot/package.json b/actions/enhance-dependabot/package.json similarity index 100% rename from workflows/enhance-dependabot/package.json rename to actions/enhance-dependabot/package.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 804c82bd1..5e9bdd202 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -486,6 +486,8 @@ importers: actions/ecr-image-exists: {} + actions/enhance-dependabot: {} + actions/free-disk-space: {} actions/get-latest-github-release: {} diff --git a/workflows/enhance-dependabot/README.md b/workflows/enhance-dependabot/README.md deleted file mode 100644 index 8c3038c09..000000000 --- a/workflows/enhance-dependabot/README.md +++ /dev/null @@ -1,129 +0,0 @@ -# enhance-dependabot - -Makes Dependabot pull requests actionable by running repository-specific setup, -generation, and cleanup steps (e.g. `go mod tidy`, code generation, formatting) -on the PR's head branch and committing the result back to the same branch. Your -repository's usual CI checks then re-run automatically against the enhanced -head, so the only thing left for a developer to do is review, approve, and -merge. - -## Recommended usage - -Add this workflow to your repository (e.g. -`.github/workflows/enhance-dependabot.yml`): - -```yaml -name: enhance-dependabot - -on: - pull_request_target: - types: [opened, synchronize, reopened] - -permissions: {} - -jobs: - enhance: - if: github.actor == 'dependabot[bot]' - permissions: - contents: write # push the enhancement commit to the dependabot branch - pull-requests: write # comment on the PR - uses: smartcontractkit/.github/.github/workflows/enhance-dependabot.yml@enhance-dependabot/v1 - with: - setup-command: make setup # toolchain, dependencies, caches, logins - enhance-command: make enhance-dependabot # tidy, generate, format, etc. - secrets: inherit -``` - -**Important:** use `pull_request_target`, not `pull_request`. Workflows -triggered by Dependabot's `pull_request` events run with a read-only -`GITHUB_TOKEN` and no Actions secrets (they are treated like fork PRs), so they -cannot push. `pull_request_target` does not have this limitation, and it runs -the workflow file from your default branch, which is what the `actor` check -above gates on. - -### Recommended repository setup - -Expose a single idempotent target, e.g. `make enhance-dependabot`, that -transforms the repository into its final post-dependency-update state. This -keeps the trigger workflow identical across repositories, and gives developers -the exact same command to run when doing manual bumps. The `enhance-command` -must be idempotent: it is re-run whenever Dependabot rebases the PR. - -### Grouping updates (fewer PRs, fewer runs) - -Dependabot can group updates so you get one PR per ecosystem per interval (e.g. -all Go updates in a single weekly PR) instead of one PR per dependency. This -also means one enhancement run and one CI rerun per PR instead of N: - -```yaml -# .github/dependabot.yml -version: 2 -updates: - - package-ecosystem: gomod - directory: / - schedule: - interval: weekly - groups: - weekly-go-deps: - applies-to: version-updates - patterns: - - "*" -``` - -### Secrets - -If your setup or enhance commands need credentials (e.g. private module access), -pass them with `secrets: inherit`. They reach the reusable workflow because of -the `pull_request_target` trigger; they would not be available with a -`pull_request` trigger. - -Optionally pass `SLACK_TOKEN` and `SLACK_CHANNEL_ID` (see inputs below) to get -Slack notifications when enhancement fails. Without them, failures are still -reported as a red check and a PR comment. - -## Inputs - -| Input | Required | Default | Description | -| ----------------- | -------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `setup-command` | no | `''` | Shell command(s) run before `enhance-command` to prepare the environment (toolchains, dependencies, logins). | -| `enhance-command` | yes | - | Idempotent shell command(s), run from the repository root, that produce the final post-update state. | -| `commit-message` | no | `chore(dependabot): apply post-update generation and cleanup` | Commit message for the enhancement commit. | -| `comment` | no | `true` | Comment on the PR when changes are pushed or a step fails. Quiet no-op otherwise. | -| `timeout-minutes` | no | `30` | Job timeout. | - -## Secrets - -| Secret | Required | Description | -| ------------------ | -------- | ------------------------------------------------------- | -| `SLACK_TOKEN` | no | Slack bot token used to notify on enhancement failures. | -| `SLACK_CHANNEL_ID` | no | Slack channel ID to notify on enhancement failures. | - -## Behavior - -- Checks out the PR head (only `dependabot/*` branches of the same repository, - otherwise it refuses to run) and runs your commands. -- If the commands produce changes: commits them as `github-actions[bot]` and - pushes to the same Dependabot branch. The push triggers a `synchronize` event, - so your usual PR checks re-run against the enhanced head. -- If the branch moved while the commands were running (Dependabot rebase), - replays the enhancement commit on top of the new head and retries the push - once. -- If the commands produce no changes: succeeds quietly (green check, no - comment). -- If a command or the push fails: fails the job (red check), comments on the PR - with the run link, and optionally notifies Slack. - -Re-runs are loop-safe: the trigger is gated on `actor == 'dependabot[bot]'`, and -the enhancement push fires `synchronize` as `github-actions[bot]`, so the -workflow does not re-trigger itself. A Dependabot rebase re-triggers enhancement -automatically, keeping the PR enhanced after rebases. - -## Known tradeoffs - -- Once the enhancement commit is pushed, `@dependabot rebase` will refuse to - rebase the PR (the branch contains non-Dependabot commits). Use - `@dependabot recreate` instead: it recreates the PR from scratch and the - enhancement workflow re-runs on the new head. -- `secrets: inherit` hands all repository secrets to the pinned reusable - workflow version, the same trust model as other reusable workflows in this - repository. diff --git a/workflows/enhance-dependabot/enhance-dependabot.yml b/workflows/enhance-dependabot/enhance-dependabot.yml deleted file mode 100644 index b3a3eace4..000000000 --- a/workflows/enhance-dependabot/enhance-dependabot.yml +++ /dev/null @@ -1,273 +0,0 @@ -name: enhance-dependabot - -on: - workflow_call: - secrets: - SLACK_TOKEN: - required: false - SLACK_CHANNEL_ID: - required: false - inputs: - setup-command: - required: false - type: string - description: | - Shell command(s) run before `enhance-command` to prepare the - environment: toolchain setup, dependency installation, registry - login, etc. Example: `make setup`. - enhance-command: - required: true - type: string - description: | - Idempotent shell command(s), run from the repository root, that - transform the repository into its final post-dependency-update - state. Example: `go mod tidy && make generate`, or a - `make enhance-dependabot` target. - commit-message: - required: false - type: string - default: "chore(dependabot): apply post-update generation and cleanup" - comment: - required: false - type: boolean - default: true - description: | - Comment on the pull request when changes are pushed, or when the - setup/enhance commands or the push fail. Quiet no-op otherwise. - timeout-minutes: - required: false - type: number - default: 30 - -permissions: { } - -jobs: - enhance: - runs-on: ubuntu-latest - timeout-minutes: ${{ inputs.timeout-minutes }} - permissions: - contents: write - pull-requests: write - env: - GH_TOKEN: ${{ github.token }} - HAS_SLACK: ${{ secrets.SLACK_TOKEN != '' && secrets.SLACK_CHANNEL_ID != '' }} - PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} - PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_URL: ${{ github.event.pull_request.html_url }} - RUN_URL: ${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }} - steps: - # Defense-in-depth for the pull_request_target based trigger: refuse to - # run on anything other than an in-repository dependabot branch. - - name: Validate pull request - run: | - if [[ "$PR_HEAD_REPO" != "$GITHUB_REPOSITORY" ]]; then - echo "::error::Refusing to run: PR head repository '$PR_HEAD_REPO' is not '$GITHUB_REPOSITORY'." >&2 - exit 1 - fi - if [[ "$PR_HEAD_REF" != dependabot/* ]]; then - echo "::error::Refusing to run: PR head branch '$PR_HEAD_REF' is not a 'dependabot/*' branch." >&2 - exit 1 - fi - - - name: Checkout PR head - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: true - - - name: Run setup command - id: setup - if: ${{ inputs.setup-command != '' }} - env: - SETUP_COMMAND: ${{ inputs.setup-command }} - run: eval "$SETUP_COMMAND" - continue-on-error: true - - - name: Run enhance command - id: enhance - env: - ENHANCE_COMMAND: ${{ inputs.enhance-command }} - run: eval "$ENHANCE_COMMAND" - continue-on-error: true - - - name: Comment on command failure - if: ${{ inputs.comment && (steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure') }} - env: - SETUP_OUTCOME: ${{ steps.setup.outcome }} - ENHANCE_OUTCOME: ${{ steps.enhance.outcome }} - run: | - failed_step="enhance-command" - if [[ "$SETUP_OUTCOME" == 'failure' ]]; then - failed_step="setup-command" - fi - { - echo "### :warning: enhance-dependabot failed" - echo - echo "The \`${failed_step}\` step failed, so this PR could not be enhanced." - echo - echo "- [Failed run](${RUN_URL})" - echo - echo "If the failure is unrelated to this dependency update, re-run the workflow. If the update itself breaks your generation/cleanup steps, close this PR and let Dependabot try again later." - } | gh pr comment "$PR_NUMBER" --body-file - - - - name: Notify command failure on Slack - if: ${{ env.HAS_SLACK == 'true' && (steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure') }} - uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_TOKEN }} - payload: | - channel: ${{ secrets.SLACK_CHANNEL_ID}} - text: | - Failed to enhance Dependabot PR in `${{ github.event.repository.name }}`! :panic: - <${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }}|Run> - PR: <${{ github.event.pull_request.html_url }}|#${{ github.event.pull_request.number }}> - - - name: Exit on command failure - if: ${{ steps.setup.outcome == 'failure' || steps.enhance.outcome == 'failure' }} - run: exit 1 - - - name: Check for changes - id: changes - run: | - if [[ -n "$(git status --porcelain)" ]]; then - echo "changed=true" | tee -a "$GITHUB_OUTPUT" - else - echo "changed=false" | tee -a "$GITHUB_OUTPUT" - fi - - - name: Commit changes - id: commit - if: ${{ steps.changes.outputs.changed == 'true' }} - env: - COMMIT_MESSAGE: ${{ inputs.commit-message }} - run: | - 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" - echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" - git diff --stat HEAD~1 HEAD | tee commit-stat.txt - - - name: Push commit - id: push - if: ${{ steps.commit.outcome == 'success' }} - run: git push origin "HEAD:${PR_HEAD_REF}" - continue-on-error: true - - # Dependabot (or someone) may have updated the branch while the enhance - # command was running, which makes the push a non-fast-forward. Replay - # the enhancement commit on top of the new head and push again. - - name: Retry push onto updated branch - id: retry - if: ${{ steps.push.outcome == 'failure' }} - run: | - commit="$(git rev-parse HEAD)" - git fetch --depth=1 origin "$PR_HEAD_REF" - git checkout --detach FETCH_HEAD - git cherry-pick "$commit" - git push origin "HEAD:${PR_HEAD_REF}" - continue-on-error: true - - - name: Comment on push failure - if: ${{ inputs.comment && steps.retry.outcome == 'failure' }} - env: - COMMIT_SHA: ${{ steps.commit.outputs.sha }} - run: | - { - echo "### :warning: enhance-dependabot could not push" - echo - echo "The enhancement commit \`${COMMIT_SHA}\` was created but could not be pushed to \`${PR_HEAD_REF}\`. The branch most likely moved (Dependabot rebase) and the replay onto the new head failed." - echo - echo "- [Failed run](${RUN_URL})" - echo - echo "Re-run the workflow from the run page, or comment \`@dependabot recreate\` to start over." - } | gh pr comment "$PR_NUMBER" --body-file - - - - name: Notify push failure on Slack - if: ${{ env.HAS_SLACK == 'true' && steps.retry.outcome == 'failure' }} - uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 - with: - method: chat.postMessage - token: ${{ secrets.SLACK_TOKEN }} - payload: | - channel: ${{ secrets.SLACK_CHANNEL_ID}} - text: | - Failed to push enhancement commit to Dependabot PR in `${{ github.event.repository.name }}`! :panic: - <${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }}|Run> - PR: <${{ github.event.pull_request.html_url }}|#${{ github.event.pull_request.number }}> - - - name: Exit on push failure - if: ${{ steps.retry.outcome == 'failure' }} - run: exit 1 - - - name: Comment on success - if: ${{ inputs.comment && (steps.push.outcome == 'success' || steps.retry.outcome == 'success') }} - env: - COMMIT_SHA: ${{ steps.commit.outputs.sha }} - REPO: ${{ github.repository }} - run: | - { - echo "### :robot: enhance-dependabot" - echo - echo "Post-update generation/cleanup was applied and pushed to this branch. The repository's usual checks will run again against the updated head." - echo - echo "- Commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" - echo "- [Run](${RUN_URL})" - echo - echo "
Changed files" - echo - echo '```' - cat commit-stat.txt - echo '```' - echo - echo "
" - } | gh pr comment "$PR_NUMBER" --body-file - - - - name: Write job summary - if: ${{ always() }} - env: - SETUP_OUTCOME: ${{ steps.setup.outcome }} - ENHANCE_OUTCOME: ${{ steps.enhance.outcome }} - PUSH_OUTCOME: ${{ steps.push.outcome }} - RETRY_OUTCOME: ${{ steps.retry.outcome }} - CHANGED: ${{ steps.changes.outputs.changed }} - COMMIT_SHA: ${{ steps.commit.outputs.sha }} - REPO: ${{ github.repository }} - run: | - { - echo "## enhance-dependabot" - echo - echo "- PR: [#${PR_NUMBER}](${PR_URL})" - if [[ "$SETUP_OUTCOME" == 'success' || "$SETUP_OUTCOME" == 'failure' ]]; then - echo "- Setup command outcome: ${SETUP_OUTCOME}" - fi - echo "- Enhance command outcome: ${ENHANCE_OUTCOME:-skipped}" - if [[ "$CHANGED" == 'true' ]]; then - echo "- Repository changes detected: true" - echo "- Enhancement commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" - echo "- Push outcome: ${PUSH_OUTCOME:-skipped}" - if [[ "$RETRY_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'failure' ]]; then - echo "- Push retry outcome: ${RETRY_OUTCOME}" - fi - else - echo "- Repository changes detected: ${CHANGED:-unknown}" - fi - echo - if [[ "$ENHANCE_OUTCOME" == 'failure' || "$RETRY_OUTCOME" == 'failure' ]]; then - echo "The PR was NOT enhanced. See the PR comment for details." - elif [[ "$CHANGED" == 'true' && ( "$PUSH_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'success' ) ]]; then - echo "The PR was enhanced and the commit was pushed." - elif [[ "$CHANGED" == 'false' ]]; then - echo "Nothing to do: the enhance command produced no changes." - fi - if [[ -f commit-stat.txt ]]; then - echo - echo "
Changed files" - echo - echo '```' - cat commit-stat.txt - echo '```' - echo - echo "
" - fi - } >> "$GITHUB_STEP_SUMMARY" From 0ffc2f3fdac9f090945ba2f89081f0dae1e47f4b Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 15 Oct 2026 12:00:22 -0400 Subject: [PATCH 3/6] fix(enhance-dependabot): guard against HEAD drift --- actions/enhance-dependabot/README.md | 7 ++++ actions/enhance-dependabot/action.yml | 48 +++++++++++++++++++-------- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/actions/enhance-dependabot/README.md b/actions/enhance-dependabot/README.md index 88da9cb84..493efae8f 100644 --- a/actions/enhance-dependabot/README.md +++ b/actions/enhance-dependabot/README.md @@ -109,6 +109,13 @@ updates: | `slack-token` | no | `''` | Slack bot token used to notify on enhancement failures. Notifications are skipped when unset. | | `slack-channel-id` | no | `''` | Slack channel ID to notify on enhancement failures. | +## Outputs + +| Output | Description | +| ------------ | ---------------------------------------------------------- | +| `changed` | Whether changes were made and pushed (`true` or `false`). | +| `commit-sha` | The SHA of the enhancement commit pushed to the PR branch. | + ## Behavior - Guards: only runs on in-repository `dependabot/*` branches whose head SHA is diff --git a/actions/enhance-dependabot/action.yml b/actions/enhance-dependabot/action.yml index b0bcbf007..5bfb73a61 100644 --- a/actions/enhance-dependabot/action.yml +++ b/actions/enhance-dependabot/action.yml @@ -37,6 +37,14 @@ inputs: default: "" description: Optional Slack channel ID to notify on enhancement failures. +outputs: + changed: + description: Whether changes were made and pushed. + value: ${{ steps.changes.outputs.changed }} + commit-sha: + description: The SHA of the enhancement commit pushed to the branch. + value: ${{ steps.retry.outputs.sha || steps.commit.outputs.sha }} + runs: using: composite steps: @@ -132,13 +140,19 @@ runs: shell: bash env: COMMIT_MESSAGE: ${{ inputs.commit-message }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | + head="$(git rev-parse HEAD)" + if [[ "$head" != "$PR_HEAD_SHA" ]]; then + echo "::error::HEAD ($head) is not the PR head SHA ($PR_HEAD_SHA). Refusing to commit." >&2 + exit 1 + 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" echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" - git diff --stat HEAD~1 HEAD | tee commit-stat.txt + git diff --stat HEAD~1 HEAD | tee "${RUNNER_TEMP}/commit-stat.txt" - name: Push commit id: push @@ -161,9 +175,15 @@ runs: run: | commit="$(git rev-parse HEAD)" git fetch --depth=1 origin "$PR_HEAD_REF" + if [[ "$(git rev-parse FETCH_HEAD)" == "$commit" ]]; then + echo "sha=$commit" | tee -a "$GITHUB_OUTPUT" + exit 0 + fi git checkout --detach FETCH_HEAD git cherry-pick "$commit" git push origin "HEAD:${PR_HEAD_REF}" + echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" + git diff --stat HEAD~1 HEAD | tee "${RUNNER_TEMP}/commit-stat.txt" continue-on-error: true - name: Comment on push failure @@ -215,7 +235,7 @@ runs: env: GH_TOKEN: ${{ inputs.github-token }} PR_NUMBER: ${{ github.event.pull_request.number }} - COMMIT_SHA: ${{ steps.commit.outputs.sha }} + COMMIT_SHA: ${{ steps.retry.outputs.sha || steps.commit.outputs.sha }} REPO: ${{ github.repository }} RUN_URL: ${{ format('https://github.com/{0}/actions/runs/{1}', @@ -228,14 +248,16 @@ runs: echo echo "- Commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" echo "- [Run](${RUN_URL})" - echo - echo "
Changed files" - echo - echo '```' - cat commit-stat.txt - echo '```' - echo - echo "
" + if [[ -f "${RUNNER_TEMP}/commit-stat.txt" ]]; then + echo + echo "
Changed files" + echo + echo '```' + cat "${RUNNER_TEMP}/commit-stat.txt" + echo '```' + echo + echo "
" + fi } | gh pr comment "$PR_NUMBER" --body-file - - name: Write job summary @@ -246,7 +268,7 @@ runs: PUSH_OUTCOME: ${{ steps.push.outcome }} RETRY_OUTCOME: ${{ steps.retry.outcome }} CHANGED: ${{ steps.changes.outputs.changed }} - COMMIT_SHA: ${{ steps.commit.outputs.sha }} + COMMIT_SHA: ${{ steps.retry.outputs.sha || steps.commit.outputs.sha }} REPO: ${{ github.repository }} PR_URL: ${{ github.event.pull_request.html_url }} run: | @@ -273,12 +295,12 @@ runs: elif [[ "$CHANGED" == 'false' ]]; then echo "Nothing to do: the enhance command produced no changes." fi - if [[ -f commit-stat.txt ]]; then + if [[ -f "${RUNNER_TEMP}/commit-stat.txt" ]]; then echo echo "
Changed files" echo echo '```' - cat commit-stat.txt + cat "${RUNNER_TEMP}/commit-stat.txt" echo '```' echo echo "
" From 5d342970a2196bccc4703d9d986904f0e1164682 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 15 Oct 2026 12:18:05 -0400 Subject: [PATCH 4/6] fix(enhance-dependabot: tests --- actions/enhance-dependabot/README.md | 19 ++- actions/enhance-dependabot/action.yml | 37 ++--- actions/enhance-dependabot/package.json | 4 +- actions/enhance-dependabot/scripts/test.sh | 133 ++++++++++++++++++ .../enhance-dependabot/scripts/validate.sh | 42 ++++++ 5 files changed, 204 insertions(+), 31 deletions(-) create mode 100755 actions/enhance-dependabot/scripts/test.sh create mode 100755 actions/enhance-dependabot/scripts/validate.sh diff --git a/actions/enhance-dependabot/README.md b/actions/enhance-dependabot/README.md index 493efae8f..a9a79422e 100644 --- a/actions/enhance-dependabot/README.md +++ b/actions/enhance-dependabot/README.md @@ -111,15 +111,16 @@ updates: ## Outputs -| Output | Description | -| ------------ | ---------------------------------------------------------- | -| `changed` | Whether changes were made and pushed (`true` or `false`). | -| `commit-sha` | The SHA of the enhancement commit pushed to the PR branch. | +| Output | Description | +| ------------ | ----------------------------------------------------------------- | +| `changed` | Whether the enhance command produced changes (`true` or `false`). | +| `commit-sha` | The SHA of the enhancement commit (set when `changed` is `true`). | ## Behavior -- Guards: only runs on in-repository `dependabot/*` branches whose head SHA is - checked out, otherwise it fails without touching anything. +- Guards: only runs when triggered by `dependabot[bot]`, on in-repository + `dependabot/*` branches whose head SHA is checked out, otherwise it fails + without touching anything. - Runs `enhance-command` in your workflow's environment (after your setup steps). - If it produces changes: commits them as `github-actions[bot]` and pushes to @@ -142,3 +143,9 @@ automatically, keeping the PR enhanced after rebases. rebase the PR (the branch contains non-Dependabot commits). Use `@dependabot recreate` instead: it recreates the PR from scratch and the enhancement workflow re-runs on the new head. + +## Development + +- Guard logic lives in [`scripts/validate.sh`](scripts/validate.sh); unit tests + (`pnpm --filter enhance-dependabot test` or `bash scripts/test.sh`) cover all + guard cases against a throwaway git fixture. diff --git a/actions/enhance-dependabot/action.yml b/actions/enhance-dependabot/action.yml index 5bfb73a61..64104cf77 100644 --- a/actions/enhance-dependabot/action.yml +++ b/actions/enhance-dependabot/action.yml @@ -39,43 +39,32 @@ inputs: outputs: changed: - description: Whether changes were made and pushed. + description: + Whether the enhance command produced changes (`true` or `false`). value: ${{ steps.changes.outputs.changed }} commit-sha: - description: The SHA of the enhancement commit pushed to the branch. + description: + The SHA of the enhancement commit (set when `changed` is `true`). value: ${{ steps.retry.outputs.sha || steps.commit.outputs.sha }} runs: using: composite steps: - # Defense-in-depth for pull_request_target based triggers: refuse to run on - # anything other than an in-repository dependabot branch, and refuse to run - # unless HEAD is the PR head (pushing a merge/base ref would corrupt the - # Dependabot branch). + # Defense-in-depth for pull_request_target based triggers: refuse to run + # unless triggered by Dependabot itself, on an in-repository dependabot + # branch, and unless HEAD is the PR head (pushing a merge/base ref would + # corrupt the Dependabot branch). The actor check enforces the documented + # consumer contract (gate the workflow on + # `github.actor == 'dependabot[bot]'`) even if a caller forgets it. + # See scripts/validate.sh (unit tested by scripts/test.sh). - name: Validate pull request shell: bash env: + PR_ACTOR: ${{ github.actor }} PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - if [[ "$PR_HEAD_REPO" != "$GITHUB_REPOSITORY" ]]; then - echo "::error::Refusing to run: PR head repository '$PR_HEAD_REPO' is not '$GITHUB_REPOSITORY'." >&2 - exit 1 - fi - if [[ "$PR_HEAD_REF" != dependabot/* ]]; then - echo "::error::Refusing to run: PR head branch '$PR_HEAD_REF' is not a 'dependabot/*' branch." >&2 - exit 1 - fi - if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then - echo "::error::No git checkout found. Check out the PR head (github.event.pull_request.head.sha) before running this action." >&2 - exit 1 - fi - head="$(git rev-parse HEAD)" - if [[ "$head" != "$PR_HEAD_SHA" ]]; then - echo "::error::HEAD ($head) is not the PR head SHA ($PR_HEAD_SHA). Check out 'github.event.pull_request.head.sha' with 'persist-credentials: true' before running this action; otherwise the push would carry a merge commit onto the Dependabot branch." >&2 - exit 1 - fi + run: ${{ github.action_path }}/scripts/validate.sh - name: Run enhance command id: enhance diff --git a/actions/enhance-dependabot/package.json b/actions/enhance-dependabot/package.json index 9065157a1..597e15e1d 100644 --- a/actions/enhance-dependabot/package.json +++ b/actions/enhance-dependabot/package.json @@ -3,7 +3,9 @@ "version": "0.0.0", "description": "", "private": true, - "scripts": {}, + "scripts": { + "test": "bash scripts/test.sh" + }, "keywords": [], "author": "@smartcontractkit", "license": "MIT", diff --git a/actions/enhance-dependabot/scripts/test.sh b/actions/enhance-dependabot/scripts/test.sh new file mode 100755 index 000000000..d38063f4d --- /dev/null +++ b/actions/enhance-dependabot/scripts/test.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Unit tests for scripts/validate.sh. Deterministic and isolated: every case +# runs against a throwaway git fixture in a temp directory. Run from anywhere: +# bash actions/enhance-dependabot/scripts/test.sh + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VALIDATE="$SCRIPT_DIR/validate.sh" + +PASS=0 +FAIL=0 +FIXTURE="" + +# shellcheck disable=SC2329 # invoked via trap below +cleanup() { + [[ -n "${FIXTURE:-}" ]] && rm -rf "$FIXTURE" +} +trap cleanup EXIT + +# make_fixture: fresh git repo with one commit on branch 'dependabot/fixture', +# in repository 'owner/repo'. Sets FIXTURE and SHA globals. +make_fixture() { + FIXTURE="$(mktemp -d)" + git -C "$FIXTURE" init --quiet --initial-branch=main + git -C "$FIXTURE" config user.name "test" + git -C "$FIXTURE" config user.email "test@example.com" + # Isolate from host gitconfig (e.g. commit.gpgsign with no secret key). + git -C "$FIXTURE" config commit.gpgsign false + touch "$FIXTURE/file" + git -C "$FIXTURE" add file + git -C "$FIXTURE" commit --quiet -m "init" + git -C "$FIXTURE" checkout --quiet -b dependabot/fixture + SHA="$(git -C "$FIXTURE" rev-parse HEAD)" +} + +# expect_fail NAME EXPECTED_SUBSTRING WORKDIR -- ENV... : run validate.sh +# with ENV assignments and a non-git-optional working directory; assert it +# exits non-zero and its output mentions EXPECTED_SUBSTRING. +expect_fail() { + local name="$1" expected="$2" workdir="$3" + shift 3 + local output status + output="$(cd "$workdir" && env "$@" bash "$VALIDATE" 2>&1)" + status=$? + if [[ $status -eq 0 ]]; then + echo "FAIL: $name: expected non-zero exit, got 0" + FAIL=$((FAIL + 1)) + return + fi + if [[ "$output" != *"$expected"* ]]; then + echo "FAIL: $name: expected output to mention '$expected', got:" + echo "$output" + FAIL=$((FAIL + 1)) + return + fi + echo "ok: $name" + PASS=$((PASS + 1)) +} + +# expect_ok NAME WORKDIR -- ENV... : run validate.sh and assert exit 0. +expect_ok() { + local name="$1" workdir="$2" + shift 2 + local output status + output="$(cd "$workdir" && env "$@" bash "$VALIDATE" 2>&1)" + status=$? + if [[ $status -ne 0 ]]; then + echo "FAIL: $name: expected exit 0, got $status:" + echo "$output" + FAIL=$((FAIL + 1)) + return + fi + echo "ok: $name" + PASS=$((PASS + 1)) +} + +make_fixture +no_git="$(mktemp -d)" + +echo "== validate.sh tests ==" + +expect_fail "rejects non-dependabot actor" "workflow actor" "$FIXTURE" \ + PR_ACTOR="malicious-user" \ + PR_HEAD_REF="dependabot/fixture" \ + PR_HEAD_REPO="owner/repo" \ + PR_HEAD_SHA="$SHA" \ + GITHUB_REPOSITORY="owner/repo" + +expect_fail "rejects fork PR" "PR head repository" "$FIXTURE" \ + PR_ACTOR="dependabot[bot]" \ + PR_HEAD_REF="dependabot/fixture" \ + PR_HEAD_REPO="attacker/repo" \ + PR_HEAD_SHA="$SHA" \ + GITHUB_REPOSITORY="owner/repo" + +expect_fail "rejects non-dependabot branch" "'dependabot/*' branch" "$FIXTURE" \ + PR_ACTOR="dependabot[bot]" \ + PR_HEAD_REF="feature/evil" \ + PR_HEAD_REPO="owner/repo" \ + PR_HEAD_SHA="$SHA" \ + GITHUB_REPOSITORY="owner/repo" + +expect_fail "rejects missing git checkout" "No git checkout found" "$no_git" \ + PR_ACTOR="dependabot[bot]" \ + PR_HEAD_REF="dependabot/fixture" \ + PR_HEAD_REPO="owner/repo" \ + PR_HEAD_SHA="$SHA" \ + GITHUB_REPOSITORY="owner/repo" + +expect_fail "rejects HEAD != PR head SHA" "is not the PR head SHA" "$FIXTURE" \ + PR_ACTOR="dependabot[bot]" \ + PR_HEAD_REF="dependabot/fixture" \ + PR_HEAD_REPO="owner/repo" \ + PR_HEAD_SHA="0000000000000000000000000000000000000000" \ + GITHUB_REPOSITORY="owner/repo" + +expect_ok "accepts valid dependabot PR head" "$FIXTURE" \ + PR_ACTOR="dependabot[bot]" \ + PR_HEAD_REF="dependabot/fixture" \ + PR_HEAD_REPO="owner/repo" \ + PR_HEAD_SHA="$SHA" \ + GITHUB_REPOSITORY="owner/repo" + +rm -rf "$no_git" + +echo +if [[ $FAIL -eq 0 ]]; then + echo "All $PASS tests passed." + exit 0 +fi +echo "$FAIL of $((PASS + FAIL)) tests failed." +exit 1 diff --git a/actions/enhance-dependabot/scripts/validate.sh b/actions/enhance-dependabot/scripts/validate.sh new file mode 100755 index 000000000..f3287c30e --- /dev/null +++ b/actions/enhance-dependabot/scripts/validate.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Guard for pull_request_target based triggers of enhance-dependabot. +# +# Fails unless the workflow was triggered by Dependabot itself, the PR head is +# an in-repository 'dependabot/*' branch, and that branch's head SHA is checked +# out as HEAD (pushing a merge/base ref would corrupt the Dependabot branch). +# The actor check enforces the documented consumer contract (gate the workflow +# on `github.actor == 'dependabot[bot]'`) even if a caller forgets it. +# +# Environment (all required): +# PR_ACTOR the actor that triggered the workflow (github.actor) +# PR_HEAD_REF the PR head branch name +# PR_HEAD_REPO the PR head repository, 'owner/repo' +# PR_HEAD_SHA the PR head commit SHA +# GITHUB_REPOSITORY the repository the workflow runs in, 'owner/repo' + +set -euo pipefail + +fail() { + echo "::error::$1" >&2 + exit 1 +} + +[[ -n "${PR_ACTOR:-}" ]] || fail "PR_ACTOR is not set." +if [[ "$PR_ACTOR" != "dependabot[bot]" ]]; then + fail "Refusing to run: workflow actor '$PR_ACTOR' is not 'dependabot[bot]'." +fi +if [[ "${PR_HEAD_REPO:-}" != "$GITHUB_REPOSITORY" ]]; then + fail "Refusing to run: PR head repository '${PR_HEAD_REPO:-}' is not '$GITHUB_REPOSITORY'." +fi +if [[ "${PR_HEAD_REF:-}" != dependabot/* ]]; then + fail "Refusing to run: PR head branch '${PR_HEAD_REF:-}' is not a 'dependabot/*' branch." +fi +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + fail "No git checkout found. Check out the PR head (github.event.pull_request.head.sha) before running this action." +fi +head="$(git rev-parse HEAD)" +if [[ "$head" != "${PR_HEAD_SHA:-}" ]]; then + fail "HEAD ($head) is not the PR head SHA (${PR_HEAD_SHA:-}). Check out 'github.event.pull_request.head.sha' with 'persist-credentials: true' before running this action; otherwise the push would carry a merge commit onto the Dependabot branch." +fi + +echo "Validated: dependabot PR head '${PR_HEAD_REF}' (${PR_HEAD_SHA:0:12}) checked out as HEAD." From 1587560c2f09c783282f433bac6834da97f7bbd3 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 15 Oct 2026 13:14:43 -0400 Subject: [PATCH 5/6] fix: push via isolated push-token, fix empty cherry-pick replay --- .changeset/lucky-pumas-enhance.md | 2 +- actions/enhance-dependabot/README.md | 68 ++++++++++++++++++--------- actions/enhance-dependabot/action.yml | 48 +++++++++++++++---- 3 files changed, 87 insertions(+), 31 deletions(-) diff --git a/.changeset/lucky-pumas-enhance.md b/.changeset/lucky-pumas-enhance.md index dc30a6469..73c417888 100644 --- a/.changeset/lucky-pumas-enhance.md +++ b/.changeset/lucky-pumas-enhance.md @@ -2,4 +2,4 @@ "enhance-dependabot": major --- -initial versioned release of the enhance-dependabot action, which makes Dependabot PRs actionable by running a repository-defined enhancement command on the checked-out PR head, committing the result, and pushing it back to the same Dependabot branch so regular CI re-runs on the enhanced code +initial versioned release of the enhance-dependabot action, which makes Dependabot PRs actionable by running a repository-defined enhancement command on the checked-out PR head, committing the result, and pushing it back to the same Dependabot branch with an isolated push token (a GitHub App token makes the push trigger the repository's usual checks; GITHUB_TOKEN pushes do not create workflow runs) diff --git a/actions/enhance-dependabot/README.md b/actions/enhance-dependabot/README.md index a9a79422e..f20968638 100644 --- a/actions/enhance-dependabot/README.md +++ b/actions/enhance-dependabot/README.md @@ -4,9 +4,10 @@ Makes Dependabot pull requests actionable. Your workflow checks out the PR head and runs any setup steps it needs (toolchains, dependencies, logins); this action then runs your `enhance-command` (e.g. `go mod tidy`, code generation, formatting), commits the result, and pushes it back to the **same Dependabot -branch**. The push triggers a `synchronize` event, so your repository's usual PR -checks re-run automatically against the enhanced code — leaving developers with -a PR they only need to review, approve, and merge. +branch** using `push-token`. Pushed with a GitHub App token, the push triggers a +`synchronize` event, so your repository's usual PR checks re-run automatically +against the enhanced code — leaving developers with a PR they only need to +review, approve, and merge. ## Usage @@ -33,10 +34,12 @@ jobs: steps: # Check out the Dependabot PR head, NOT the default merge/base ref. # The action refuses to run if HEAD is not the PR head SHA. + # persist-credentials: false keeps the write credential off disk while + # the enhance-command runs; the action pushes with push-token itself. - uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.sha }} - persist-credentials: true + persist-credentials: false # Any setup steps your enhance command needs — you have full control: - uses: actions/setup-go@v7 @@ -51,6 +54,10 @@ jobs: - uses: smartcontractkit/.github/actions/enhance-dependabot@enhance-dependabot/v1 with: enhance-command: go mod tidy && make generate + # Recommended: a GitHub App installation token so the push triggers + # your usual PR checks. GITHUB_TOKEN pushes do not create workflow + # runs. e.g. mint one first with actions/create-github-app-token. + push-token: ${{ secrets.GH_APP_TOKEN }} env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # if codegen needs private packages # SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} # optional failure notifications @@ -65,9 +72,16 @@ jobs: `pull_request_target` does not have this limitation, and it runs the workflow file from your default branch, which is what the `actor` check above gates on. - Check out `github.event.pull_request.head.sha` with - `persist-credentials: true`. The action refuses to run if HEAD is not the PR + `persist-credentials: false`. The action refuses to run if HEAD is not the PR head SHA, since pushing a merge/base ref would carry a merge commit onto the - Dependabot branch. + Dependabot branch. Keeping credentials unpersisted means no write credential + is on disk while the enhance command runs; the action pushes with `push-token` + itself. +- Pushes made with `GITHUB_TOKEN` do not create new workflow runs, so with the + default `push-token` your usual PR checks will **not** re-run after + enhancement. Pass a GitHub App installation token or PAT as `push-token` to + trigger them; the token is used only by the push steps and is never exposed to + the enhance command. - The `enhance-command` must be idempotent: it is re-run whenever Dependabot rebases the PR. @@ -100,14 +114,15 @@ updates: ## Inputs -| Input | Required | Default | Description | -| ------------------ | -------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| `enhance-command` | yes | - | Idempotent shell command(s), run from the repository root, that produce the final post-update state. | -| `commit-message` | no | `chore(dependabot): apply post-update generation and cleanup` | Commit message for the enhancement commit. | -| `comment` | no | `true` | Comment on the PR when changes are pushed or a step fails. Quiet no-op otherwise. | -| `github-token` | no | `github.token` | GitHub token used to comment on the pull request. | -| `slack-token` | no | `''` | Slack bot token used to notify on enhancement failures. Notifications are skipped when unset. | -| `slack-channel-id` | no | `''` | Slack channel ID to notify on enhancement failures. | +| Input | Required | Default | Description | +| ------------------ | -------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enhance-command` | yes | - | Idempotent shell command(s), run from the repository root, that produce the final post-update state. | +| `commit-message` | no | `chore(dependabot): apply post-update generation and cleanup` | Commit message for the enhancement commit. | +| `comment` | no | `true` | Comment on the PR when changes are pushed or a step fails. Quiet no-op otherwise. | +| `github-token` | no | `github.token` | GitHub token used to comment on the pull request. | +| `push-token` | no | `github.token` | Token used only by the push steps to push the enhancement commit. Pass a GitHub App token so the push triggers the repository's usual PR checks (GITHUB_TOKEN pushes do not create workflow runs). | +| `slack-token` | no | `''` | Slack bot token used to notify on enhancement failures. Notifications are skipped when unset. | +| `slack-channel-id` | no | `''` | Slack channel ID to notify on enhancement failures. | ## Outputs @@ -124,18 +139,23 @@ updates: - Runs `enhance-command` in your workflow's environment (after your setup steps). - If it produces changes: commits them as `github-actions[bot]` and pushes to - the same Dependabot branch. The push triggers a `synchronize` event, so your - usual PR checks re-run against the enhanced head. + the same Dependabot branch with `push-token`. Pushed with a GitHub App token, + the push triggers a `synchronize` event and your usual PR checks re-run + against the enhanced head; pushes made with `GITHUB_TOKEN` do not create new + workflow runs. - If the branch moved while the command was running (Dependabot rebase), replays - the enhancement commit on top of the new head and retries the push once. + the enhancement commit on top of the new head and retries the push once. If + the branch already contains the same changes (concurrent run), the empty + replay is treated as success. - If it produces no changes: succeeds quietly (green check, no comment). - If the command or the push fails: fails the job (red check), comments on the PR with the run link, and optionally notifies Slack. -Re-runs are loop-safe: the trigger is gated on `actor == 'dependabot[bot]'`, and -the enhancement push fires `synchronize` as `github-actions[bot]`, so the -workflow does not re-trigger itself. A Dependabot rebase re-triggers enhancement -automatically, keeping the PR enhanced after rebases. +Re-runs are loop-safe: the workflow trigger is gated on +`actor == 'dependabot[bot]'`, so the `synchronize` event fired by an App-token +enhancement push does not re-trigger the enhance workflow itself (and a +`GITHUB_TOKEN` push fires no event at all). A Dependabot rebase re-triggers +enhancement automatically, keeping the PR enhanced after rebases. ## Known tradeoffs @@ -143,6 +163,12 @@ automatically, keeping the PR enhanced after rebases. rebase the PR (the branch contains non-Dependabot commits). Use `@dependabot recreate` instead: it recreates the PR from scratch and the enhancement workflow re-runs on the new head. +- The enhance command runs code that a dependency update can influence + (generators, postinstall scripts, Make targets). It runs with no write + credential on disk (`persist-credentials: false`) and `push-token` is used + only by the push steps, but a malicious dependency version can still execute + arbitrary code inside your workflow. That is the same trust decision as + running your CI on Dependabot PRs; review dependency diffs. ## Development diff --git a/actions/enhance-dependabot/action.yml b/actions/enhance-dependabot/action.yml index 64104cf77..ab9705842 100644 --- a/actions/enhance-dependabot/action.yml +++ b/actions/enhance-dependabot/action.yml @@ -1,9 +1,11 @@ name: enhance-dependabot description: | Runs a repository-defined enhancement command on a Dependabot PR's checked-out - head, commits the result, and pushes it back to the same Dependabot branch so - the repository's usual checks re-run on the enhanced code. Setup (toolchains, - dependencies, logins) is done by the calling workflow before this action. + head, commits the result, and pushes it back to the same Dependabot branch. + Push with a GitHub App token (`push-token`) so the push triggers the + repository's usual checks; pushes made with GITHUB_TOKEN do not create new + workflow runs. Setup (toolchains, dependencies, logins) is done by the + calling workflow before this action. inputs: enhance-command: @@ -26,6 +28,15 @@ inputs: required: false default: ${{ github.token }} description: GitHub token used to comment on the pull request. + push-token: + required: false + default: ${{ github.token }} + description: | + Token used only by the push steps to push the enhancement commit to the + Dependabot branch; it is never exposed to the enhance-command. Pushes made + with GITHUB_TOKEN do not create new workflow runs, so pass a GitHub App + installation token (e.g. from actions/create-github-app-token) to make + the push trigger the repository's usual PR checks. slack-token: required: false default: "" @@ -64,7 +75,8 @@ runs: PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: ${{ github.action_path }}/scripts/validate.sh + VALIDATE_SCRIPT: ${{ github.action_path }}/scripts/validate.sh + run: "$VALIDATE_SCRIPT" - name: Run enhance command id: enhance @@ -149,7 +161,11 @@ runs: shell: bash env: PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} - run: git push origin "HEAD:${PR_HEAD_REF}" + PUSH_TOKEN: ${{ inputs.push-token }} + run: + git push + "https://x-access-token:${PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + "HEAD:${PR_HEAD_REF}" continue-on-error: true # Dependabot (or someone) may have updated the branch while the enhance @@ -161,16 +177,30 @@ runs: shell: bash env: PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PUSH_TOKEN: ${{ inputs.push-token }} run: | + remote="https://x-access-token:${PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" commit="$(git rev-parse HEAD)" - git fetch --depth=1 origin "$PR_HEAD_REF" + git fetch --depth=1 "$remote" "$PR_HEAD_REF" if [[ "$(git rev-parse FETCH_HEAD)" == "$commit" ]]; then echo "sha=$commit" | tee -a "$GITHUB_OUTPUT" exit 0 fi git checkout --detach FETCH_HEAD - git cherry-pick "$commit" - git push origin "HEAD:${PR_HEAD_REF}" + if ! git cherry-pick "$commit"; then + # Another run may have already pushed the same generated changes, in + # which case the replay is an empty cherry-pick. Treat only an empty, + # conflict-free pick as success (the branch already has the changes); + # any other failure is real. + if git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null && + [[ -z "$(git ls-files -u)" ]] && git diff --cached --quiet; then + git cherry-pick --skip + echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" + exit 0 + fi + exit 1 + fi + git push "$remote" "HEAD:${PR_HEAD_REF}" echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" git diff --stat HEAD~1 HEAD | tee "${RUNNER_TEMP}/commit-stat.txt" continue-on-error: true @@ -233,7 +263,7 @@ runs: { echo "### :robot: enhance-dependabot" echo - echo "Post-update generation/cleanup was applied and pushed to this branch. The repository's usual checks will run again against the updated head." + echo "Post-update generation/cleanup was applied and pushed to this branch. If pushed with a GitHub App token (\`push-token\`), the repository's usual checks re-run against the updated head; pushes made with GITHUB_TOKEN do not trigger workflow runs." echo echo "- Commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" echo "- [Run](${RUN_URL})" From c0dd3a9f17102b81e429ae7842d7dcf7bdf7fe01 Mon Sep 17 00:00:00 2001 From: Adam Hamrick Date: Thu, 15 Oct 2026 16:10:40 -0400 Subject: [PATCH 6/6] fix(docs) --- actions/enhance-dependabot/README.md | 106 ++++++++------ actions/enhance-dependabot/action.yml | 109 +++++--------- actions/enhance-dependabot/scripts/push.sh | 46 ++++++ actions/enhance-dependabot/scripts/test.sh | 134 ++++++++++++++++-- .../enhance-dependabot/scripts/validate.sh | 9 +- 5 files changed, 278 insertions(+), 126 deletions(-) create mode 100755 actions/enhance-dependabot/scripts/push.sh diff --git a/actions/enhance-dependabot/README.md b/actions/enhance-dependabot/README.md index f20968638..372f13c5f 100644 --- a/actions/enhance-dependabot/README.md +++ b/actions/enhance-dependabot/README.md @@ -28,8 +28,12 @@ jobs: if: github.actor == 'dependabot[bot]' runs-on: ubuntu-latest timeout-minutes: 30 + # A Dependabot rebase cancels the in-progress run for the old head. + concurrency: + group: enhance-dependabot-${{ github.event.pull_request.number }} + cancel-in-progress: true permissions: - contents: write # push the enhancement commit to the dependabot branch + contents: read # push-token pushes; use write if push-token is GITHUB_TOKEN pull-requests: write # comment on the PR steps: # Check out the Dependabot PR head, NOT the default merge/base ref. @@ -51,17 +55,25 @@ jobs: # - run: pnpm install --frozen-lockfile # - uses: ...anything else... + # A GitHub App installation token (contents: write) makes the push trigger + # your usual PR checks; GITHUB_TOKEN pushes do not create workflow runs. + - uses: actions/create-github-app-token@v2 + id: app-token + with: + app-id: ${{ secrets.ENHANCE_DEPENDABOT_APP_ID }} + private-key: ${{ secrets.ENHANCE_DEPENDABOT_APP_PRIVATE_KEY }} + - uses: smartcontractkit/.github/actions/enhance-dependabot@enhance-dependabot/v1 with: enhance-command: go mod tidy && make generate - # Recommended: a GitHub App installation token so the push triggers - # your usual PR checks. GITHUB_TOKEN pushes do not create workflow - # runs. e.g. mint one first with actions/create-github-app-token. - push-token: ${{ secrets.GH_APP_TOKEN }} - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # if codegen needs private packages - # SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} # optional failure notifications - # SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }} + push-token: ${{ steps.app-token.outputs.token }} + # Optional failure notifications: + # slack-token: ${{ secrets.SLACK_BOT_TOKEN }} + # slack-channel-id: ${{ secrets.SLACK_CHANNEL_ID }} + # Credentials the enhance command needs (e.g. private packages for + # codegen). Keep them read-only: the command runs dependency code. + # env: + # NODE_AUTH_TOKEN: ${{ secrets.NPM_READ_TOKEN }} ``` **Important:** @@ -74,16 +86,20 @@ jobs: - Check out `github.event.pull_request.head.sha` with `persist-credentials: false`. The action refuses to run if HEAD is not the PR head SHA, since pushing a merge/base ref would carry a merge commit onto the - Dependabot branch. Keeping credentials unpersisted means no write credential - is on disk while the enhance command runs; the action pushes with `push-token` - itself. + Dependabot branch. It also refuses persisted checkout credentials: they would + put a write token on disk while the enhance command runs, and they override + `push-token` on push. - Pushes made with `GITHUB_TOKEN` do not create new workflow runs, so with the default `push-token` your usual PR checks will **not** re-run after enhancement. Pass a GitHub App installation token or PAT as `push-token` to - trigger them; the token is used only by the push steps and is never exposed to - the enhance command. + trigger them; the token is passed only to the push step. +- Keep the job's `concurrency` group: a Dependabot rebase supersedes the run for + the old head. - The `enhance-command` must be idempotent: it is re-run whenever Dependabot rebases the PR. +- The action commits every change and untracked file left in the working tree, + so make sure tool caches and build artifacts from your setup steps and the + enhance command are gitignored. ### Recommended repository setup @@ -114,39 +130,40 @@ updates: ## Inputs -| Input | Required | Default | Description | -| ------------------ | -------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enhance-command` | yes | - | Idempotent shell command(s), run from the repository root, that produce the final post-update state. | -| `commit-message` | no | `chore(dependabot): apply post-update generation and cleanup` | Commit message for the enhancement commit. | -| `comment` | no | `true` | Comment on the PR when changes are pushed or a step fails. Quiet no-op otherwise. | -| `github-token` | no | `github.token` | GitHub token used to comment on the pull request. | -| `push-token` | no | `github.token` | Token used only by the push steps to push the enhancement commit. Pass a GitHub App token so the push triggers the repository's usual PR checks (GITHUB_TOKEN pushes do not create workflow runs). | -| `slack-token` | no | `''` | Slack bot token used to notify on enhancement failures. Notifications are skipped when unset. | -| `slack-channel-id` | no | `''` | Slack channel ID to notify on enhancement failures. | +| Input | Required | Default | Description | +| ------------------ | -------- | ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enhance-command` | yes | - | Idempotent shell command(s), run from the repository root, that produce the final post-update state. | +| `commit-message` | no | `chore(dependabot): apply post-update generation and cleanup` | Commit message for the enhancement commit. | +| `comment` | no | `true` | Comment on the PR when changes are pushed or a step fails. Quiet no-op otherwise. | +| `github-token` | no | `github.token` | GitHub token used to comment on the pull request. | +| `push-token` | no | `github.token` | Token used to push the enhancement commit, passed only to the push step. Pass a GitHub App token so the push triggers the repository's usual PR checks (GITHUB_TOKEN pushes do not create workflow runs). | +| `slack-token` | no | `''` | Slack bot token used to notify on enhancement failures. Notifications are skipped when unset. | +| `slack-channel-id` | no | `''` | Slack channel ID to notify on enhancement failures. | ## Outputs | Output | Description | | ------------ | ----------------------------------------------------------------- | | `changed` | Whether the enhance command produced changes (`true` or `false`). | -| `commit-sha` | The SHA of the enhancement commit (set when `changed` is `true`). | +| `commit-sha` | The SHA of the enhancement commit (set when it was pushed). | ## Behavior - Guards: only runs when triggered by `dependabot[bot]`, on in-repository - `dependabot/*` branches whose head SHA is checked out, otherwise it fails - without touching anything. + `dependabot/*` branches whose head SHA is checked out without persisted + credentials, otherwise it fails without touching anything. - Runs `enhance-command` in your workflow's environment (after your setup steps). - If it produces changes: commits them as `github-actions[bot]` and pushes to - the same Dependabot branch with `push-token`. Pushed with a GitHub App token, - the push triggers a `synchronize` event and your usual PR checks re-run - against the enhanced head; pushes made with `GITHUB_TOKEN` do not create new - workflow runs. -- If the branch moved while the command was running (Dependabot rebase), replays - the enhancement commit on top of the new head and retries the push once. If - the branch already contains the same changes (concurrent run), the empty - replay is treated as success. + the same Dependabot branch with `push-token`, ignoring git config and hooks + the enhance command could have planted. Pushed with a GitHub App token, the + push triggers a `synchronize` event and your usual PR checks re-run against + the enhanced head; pushes made with `GITHUB_TOKEN` do not create new workflow + runs. +- If the branch moved while the command was running (Dependabot rebase), the run + is superseded: it pushes nothing and succeeds, since the new head gets its own + enhancement run. Replaying output generated from the old head could land stale + results. - If it produces no changes: succeeds quietly (green check, no comment). - If the command or the push fails: fails the job (red check), comments on the PR with the run link, and optionally notifies Slack. @@ -164,14 +181,19 @@ enhancement automatically, keeping the PR enhanced after rebases. `@dependabot recreate` instead: it recreates the PR from scratch and the enhancement workflow re-runs on the new head. - The enhance command runs code that a dependency update can influence - (generators, postinstall scripts, Make targets). It runs with no write - credential on disk (`persist-credentials: false`) and `push-token` is used - only by the push steps, but a malicious dependency version can still execute - arbitrary code inside your workflow. That is the same trust decision as - running your CI on Dependabot PRs; review dependency diffs. + (generators, postinstall scripts, Make targets). The action keeps credentials + away from it where it can: no write credential is on disk + (`persist-credentials: false`, enforced), `push-token` is passed only to the + push step, and the push ignores planted git config and hooks. This is + best-effort: code that runs earlier in a job can still reach later steps (e.g. + via `$GITHUB_ENV` or a background process), so a malicious dependency version + can still act with the job's credentials, including `push-token`. That is the + same trust decision as running your CI on Dependabot PRs; review dependency + diffs. ## Development -- Guard logic lives in [`scripts/validate.sh`](scripts/validate.sh); unit tests - (`pnpm --filter enhance-dependabot test` or `bash scripts/test.sh`) cover all - guard cases against a throwaway git fixture. +- Guard and push logic live in [`scripts/validate.sh`](scripts/validate.sh) and + [`scripts/push.sh`](scripts/push.sh); unit tests + (`pnpm --filter enhance-dependabot test` or `bash scripts/test.sh`) cover them + against throwaway git fixtures. diff --git a/actions/enhance-dependabot/action.yml b/actions/enhance-dependabot/action.yml index ab9705842..44ba47663 100644 --- a/actions/enhance-dependabot/action.yml +++ b/actions/enhance-dependabot/action.yml @@ -32,11 +32,12 @@ inputs: required: false default: ${{ github.token }} description: | - Token used only by the push steps to push the enhancement commit to the - Dependabot branch; it is never exposed to the enhance-command. Pushes made - with GITHUB_TOKEN do not create new workflow runs, so pass a GitHub App - installation token (e.g. from actions/create-github-app-token) to make - the push trigger the repository's usual PR checks. + Token used to push the enhancement commit to the Dependabot branch. It is + passed only to the push step, which ignores git config and hooks the + enhance-command could have planted. Pushes made with GITHUB_TOKEN do not + create new workflow runs, so pass a GitHub App installation token (e.g. + from actions/create-github-app-token) to make the push trigger the + repository's usual PR checks. slack-token: required: false default: "" @@ -54,9 +55,10 @@ outputs: Whether the enhance command produced changes (`true` or `false`). value: ${{ steps.changes.outputs.changed }} commit-sha: - description: - The SHA of the enhancement commit (set when `changed` is `true`). - value: ${{ steps.retry.outputs.sha || steps.commit.outputs.sha }} + description: The SHA of the enhancement commit (set when it was pushed). + value: + ${{ steps.push.outputs.result == 'pushed' && steps.commit.outputs.sha || + '' }} runs: using: composite @@ -155,58 +157,25 @@ runs: echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" git diff --stat HEAD~1 HEAD | tee "${RUNNER_TEMP}/commit-stat.txt" + # Pushes with push-token, ignoring git config and hooks planted by the + # enhance command. If Dependabot (or someone) moved the branch while the + # command was running, the run is superseded: it pushes nothing and exits 0 + # (result=superseded), since the newer head gets its own enhancement run. + # See scripts/push.sh (unit tested by scripts/test.sh). - name: Push commit id: push if: ${{ steps.commit.outcome == 'success' }} shell: bash env: PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PUSH_TOKEN: ${{ inputs.push-token }} - run: - git push - "https://x-access-token:${PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - "HEAD:${PR_HEAD_REF}" - continue-on-error: true - - # Dependabot (or someone) may have updated the branch while the enhance - # command was running, which makes the push a non-fast-forward. Replay the - # enhancement commit on top of the new head and push again. - - name: Retry push onto updated branch - id: retry - if: ${{ steps.push.outcome == 'failure' }} - shell: bash - env: - PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} - PUSH_TOKEN: ${{ inputs.push-token }} - run: | - remote="https://x-access-token:${PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - commit="$(git rev-parse HEAD)" - git fetch --depth=1 "$remote" "$PR_HEAD_REF" - if [[ "$(git rev-parse FETCH_HEAD)" == "$commit" ]]; then - echo "sha=$commit" | tee -a "$GITHUB_OUTPUT" - exit 0 - fi - git checkout --detach FETCH_HEAD - if ! git cherry-pick "$commit"; then - # Another run may have already pushed the same generated changes, in - # which case the replay is an empty cherry-pick. Treat only an empty, - # conflict-free pick as success (the branch already has the changes); - # any other failure is real. - if git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null && - [[ -z "$(git ls-files -u)" ]] && git diff --cached --quiet; then - git cherry-pick --skip - echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" - exit 0 - fi - exit 1 - fi - git push "$remote" "HEAD:${PR_HEAD_REF}" - echo "sha=$(git rev-parse HEAD)" | tee -a "$GITHUB_OUTPUT" - git diff --stat HEAD~1 HEAD | tee "${RUNNER_TEMP}/commit-stat.txt" + PUSH_SCRIPT: ${{ github.action_path }}/scripts/push.sh + run: "$PUSH_SCRIPT" continue-on-error: true - name: Comment on push failure - if: ${{ inputs.comment == 'true' && steps.retry.outcome == 'failure' }} + if: ${{ inputs.comment == 'true' && steps.push.outcome == 'failure' }} shell: bash env: GH_TOKEN: ${{ inputs.github-token }} @@ -220,7 +189,7 @@ runs: { echo "### :warning: enhance-dependabot could not push" echo - echo "The enhancement commit \`${COMMIT_SHA}\` was created but could not be pushed to \`${PR_HEAD_REF}\`. The branch most likely moved (Dependabot rebase) and the replay onto the new head failed." + echo "The enhancement commit \`${COMMIT_SHA}\` was created but could not be pushed to \`${PR_HEAD_REF}\`." echo echo "- [Failed run](${RUN_URL})" echo @@ -230,7 +199,7 @@ runs: - name: Notify push failure on Slack if: ${{ inputs.slack-token != '' && inputs.slack-channel-id != '' && - steps.retry.outcome == 'failure' }} + steps.push.outcome == 'failure' }} uses: slackapi/slack-github-action@af78098f536edbc4de71162a307590698245be95 # v3.0.1 with: method: chat.postMessage @@ -242,20 +211,20 @@ runs: PR: <${{ github.event.pull_request.html_url }}|#${{ github.event.pull_request.number }}> - name: Exit on push failure - if: ${{ steps.retry.outcome == 'failure' }} + if: ${{ steps.push.outcome == 'failure' }} shell: bash run: exit 1 - name: Comment on success if: - ${{ inputs.comment == 'true' && (steps.push.outcome == 'success' || - steps.retry.outcome == 'success') }} + ${{ inputs.comment == 'true' && steps.push.outputs.result == 'pushed' }} shell: bash env: GH_TOKEN: ${{ inputs.github-token }} PR_NUMBER: ${{ github.event.pull_request.number }} - COMMIT_SHA: ${{ steps.retry.outputs.sha || steps.commit.outputs.sha }} + COMMIT_SHA: ${{ steps.commit.outputs.sha }} REPO: ${{ github.repository }} + TRIGGERS_CHECKS: ${{ inputs.push-token != github.token }} RUN_URL: ${{ format('https://github.com/{0}/actions/runs/{1}', github.repository, github.run_id) }} @@ -263,7 +232,11 @@ runs: { echo "### :robot: enhance-dependabot" echo - echo "Post-update generation/cleanup was applied and pushed to this branch. If pushed with a GitHub App token (\`push-token\`), the repository's usual checks re-run against the updated head; pushes made with GITHUB_TOKEN do not trigger workflow runs." + if [[ "$TRIGGERS_CHECKS" == 'true' ]]; then + echo "Post-update generation/cleanup was applied and pushed to this branch. The repository's usual checks re-run against the updated head." + else + echo "Post-update generation/cleanup was applied and pushed to this branch with \`GITHUB_TOKEN\`, which does not trigger workflow runs. Close and reopen this PR to run checks, or configure a GitHub App token as \`push-token\`." + fi echo echo "- Commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" echo "- [Run](${RUN_URL})" @@ -285,9 +258,9 @@ runs: env: ENHANCE_OUTCOME: ${{ steps.enhance.outcome }} PUSH_OUTCOME: ${{ steps.push.outcome }} - RETRY_OUTCOME: ${{ steps.retry.outcome }} + PUSH_RESULT: ${{ steps.push.outputs.result }} CHANGED: ${{ steps.changes.outputs.changed }} - COMMIT_SHA: ${{ steps.retry.outputs.sha || steps.commit.outputs.sha }} + COMMIT_SHA: ${{ steps.commit.outputs.sha }} REPO: ${{ github.repository }} PR_URL: ${{ github.event.pull_request.html_url }} run: | @@ -296,21 +269,17 @@ runs: echo echo "- PR: ${PR_URL}" echo "- Enhance command outcome: ${ENHANCE_OUTCOME:-skipped}" + echo "- Repository changes detected: ${CHANGED:-unknown}" if [[ "$CHANGED" == 'true' ]]; then - echo "- Repository changes detected: true" - echo "- Enhancement commit: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})" - echo "- Push outcome: ${PUSH_OUTCOME:-skipped}" - if [[ "$RETRY_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'failure' ]]; then - echo "- Push retry outcome: ${RETRY_OUTCOME}" - fi - else - echo "- Repository changes detected: ${CHANGED:-unknown}" + echo "- Push outcome: ${PUSH_RESULT:-${PUSH_OUTCOME:-skipped}}" fi echo - if [[ "$ENHANCE_OUTCOME" == 'failure' || "$RETRY_OUTCOME" == 'failure' ]]; then + if [[ "$ENHANCE_OUTCOME" == 'failure' || "$PUSH_OUTCOME" == 'failure' ]]; then echo "The PR was NOT enhanced. See the PR comment for details." - elif [[ "$CHANGED" == 'true' && ( "$PUSH_OUTCOME" == 'success' || "$RETRY_OUTCOME" == 'success' ) ]]; then - echo "The PR was enhanced and the commit was pushed." + elif [[ "$PUSH_RESULT" == 'pushed' ]]; then + echo "The PR was enhanced: [\`${COMMIT_SHA:0:12}\`](https://github.com/${REPO}/commit/${COMMIT_SHA}) was pushed." + elif [[ "$PUSH_RESULT" == 'superseded' ]]; then + echo "Nothing was pushed: the branch moved while this run was in progress, and the newer head gets its own enhancement run." elif [[ "$CHANGED" == 'false' ]]; then echo "Nothing to do: the enhance command produced no changes." fi diff --git a/actions/enhance-dependabot/scripts/push.sh b/actions/enhance-dependabot/scripts/push.sh new file mode 100755 index 000000000..93031a544 --- /dev/null +++ b/actions/enhance-dependabot/scripts/push.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Pushes the enhancement commit (HEAD) to the Dependabot branch with PUSH_TOKEN. +# +# The enhance command ran in this checkout, so its git config and hooks are +# untrusted: drop local url/http/credential/include config (which could redirect +# or leak the token), ignore global/system config, and disable hooks. +# +# If the push is rejected because the branch moved off PR_HEAD_SHA (e.g. a +# Dependabot rebase), this run is superseded and exits 0 without pushing: the +# rebase triggers a new run that enhances the new head, and replaying output +# generated from the old head could land stale results. +# +# Environment: +# PUSH_TOKEN token used to push (required) +# PR_HEAD_REF the PR head branch name (required) +# PR_HEAD_SHA the PR head SHA the enhancement was built on (required) +# GITHUB_REPOSITORY the repository the workflow runs in, 'owner/repo' (required) +# GITHUB_OUTPUT step outputs file; receives 'result=pushed|superseded' +# PUSH_REMOTE_URL push URL override (tests); defaults to the GitHub repository + +set -euo pipefail + +remote="${PUSH_REMOTE_URL:-https://x-access-token:${PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git}" + +{ git config --local --name-only --get-regexp '^(url|http|credential|include|includeif)\.' || true; } | + sort -u | while read -r key; do git config --local --unset-all "$key"; done + +git_safe() { + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \ + git -c core.hooksPath=/dev/null -c core.fsmonitor=false "$@" +} + +if git_safe push "$remote" "HEAD:${PR_HEAD_REF}"; then + echo "result=pushed" | tee -a "$GITHUB_OUTPUT" + exit 0 +fi + +remote_head="$(git_safe ls-remote "$remote" "refs/heads/${PR_HEAD_REF}" | cut -f1)" +if [[ "$remote_head" != "$PR_HEAD_SHA" ]]; then + echo "::notice::Branch '${PR_HEAD_REF}' moved off ${PR_HEAD_SHA:0:12} while this run was in progress; skipping the push, the newer run enhances the new head." + echo "result=superseded" | tee -a "$GITHUB_OUTPUT" + exit 0 +fi + +echo "::error::Failed to push the enhancement commit to '${PR_HEAD_REF}'." >&2 +exit 1 diff --git a/actions/enhance-dependabot/scripts/test.sh b/actions/enhance-dependabot/scripts/test.sh index d38063f4d..6b8e49b72 100755 --- a/actions/enhance-dependabot/scripts/test.sh +++ b/actions/enhance-dependabot/scripts/test.sh @@ -1,32 +1,40 @@ #!/usr/bin/env bash -# Unit tests for scripts/validate.sh. Deterministic and isolated: every case -# runs against a throwaway git fixture in a temp directory. Run from anywhere: +# Unit tests for scripts/validate.sh and scripts/push.sh. Deterministic and +# isolated: every case runs against throwaway git fixtures in a temp directory. +# Run from anywhere: # bash actions/enhance-dependabot/scripts/test.sh set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" VALIDATE="$SCRIPT_DIR/validate.sh" +PUSH="$SCRIPT_DIR/push.sh" PASS=0 FAIL=0 -FIXTURE="" +ROOT="$(mktemp -d "${TMPDIR:-/tmp}/enhance-dependabot-test.XXXXXX")" || exit 1 +OUTPUT="" # shellcheck disable=SC2329 # invoked via trap below cleanup() { - [[ -n "${FIXTURE:-}" ]] && rm -rf "$FIXTURE" + rm -rf "$ROOT" } trap cleanup EXIT +# init_repo DIR: git repo isolated from host gitconfig (e.g. commit.gpgsign +# with no secret key). +init_repo() { + git -C "$1" init --quiet --initial-branch=main + git -C "$1" config user.name "test" + git -C "$1" config user.email "test@example.com" + git -C "$1" config commit.gpgsign false +} + # make_fixture: fresh git repo with one commit on branch 'dependabot/fixture', # in repository 'owner/repo'. Sets FIXTURE and SHA globals. make_fixture() { - FIXTURE="$(mktemp -d)" - git -C "$FIXTURE" init --quiet --initial-branch=main - git -C "$FIXTURE" config user.name "test" - git -C "$FIXTURE" config user.email "test@example.com" - # Isolate from host gitconfig (e.g. commit.gpgsign with no secret key). - git -C "$FIXTURE" config commit.gpgsign false + FIXTURE="$(mktemp -d "$ROOT/fixture.XXXXXX")" + init_repo "$FIXTURE" touch "$FIXTURE/file" git -C "$FIXTURE" add file git -C "$FIXTURE" commit --quiet -m "init" @@ -34,6 +42,57 @@ make_fixture() { SHA="$(git -C "$FIXTURE" rev-parse HEAD)" } +# make_push_fixture: bare remote whose 'dependabot/fixture' branch is at +# BASE_SHA, and a clone whose HEAD is an enhancement commit on top of it. +# Sets PUSH_DIR, REMOTE_URL, CLONE, BASE_SHA and OUT (GITHUB_OUTPUT) globals. +make_push_fixture() { + PUSH_DIR="$(mktemp -d "$ROOT/push.XXXXXX")" + git init --quiet --bare "$PUSH_DIR/remote.git" + REMOTE_URL="file://$PUSH_DIR/remote.git" + CLONE="$PUSH_DIR/clone" + mkdir "$CLONE" + init_repo "$CLONE" + echo "base" >"$CLONE/file" + git -C "$CLONE" add file + git -C "$CLONE" commit --quiet -m "base" + BASE_SHA="$(git -C "$CLONE" rev-parse HEAD)" + git -C "$CLONE" push --quiet "$REMOTE_URL" HEAD:refs/heads/dependabot/fixture + echo "enhanced" >"$CLONE/file" + git -C "$CLONE" commit --quiet -am "enhance" + OUT="$PUSH_DIR/github-output" + : >"$OUT" +} + +# run_push: run push.sh in the push fixture clone. Sets OUTPUT and STATUS. +run_push() { + OUTPUT="$(cd "$CLONE" && env PUSH_TOKEN="unused" \ + PR_HEAD_REF="dependabot/fixture" \ + PR_HEAD_SHA="$BASE_SHA" \ + GITHUB_REPOSITORY="owner/repo" \ + GITHUB_OUTPUT="$OUT" \ + PUSH_REMOTE_URL="$REMOTE_URL" \ + bash "$PUSH" 2>&1)" + STATUS=$? +} + +remote_head() { + git -C "$PUSH_DIR/remote.git" rev-parse dependabot/fixture +} + +# check NAME CMD... : assert CMD succeeds; print the last script output if not. +check() { + local name="$1" + shift + if "$@"; then + echo "ok: $name" + PASS=$((PASS + 1)) + return + fi + echo "FAIL: $name; script output:" + echo "$OUTPUT" + FAIL=$((FAIL + 1)) +} + # expect_fail NAME EXPECTED_SUBSTRING WORKDIR -- ENV... : run validate.sh # with ENV assignments and a non-git-optional working directory; assert it # exits non-zero and its output mentions EXPECTED_SUBSTRING. @@ -76,7 +135,7 @@ expect_ok() { } make_fixture -no_git="$(mktemp -d)" +no_git="$(mktemp -d "$ROOT/no-git.XXXXXX")" echo "== validate.sh tests ==" @@ -115,6 +174,15 @@ expect_fail "rejects HEAD != PR head SHA" "is not the PR head SHA" "$FIXTURE" \ PR_HEAD_SHA="0000000000000000000000000000000000000000" \ GITHUB_REPOSITORY="owner/repo" +git -C "$FIXTURE" config "http.https://github.com/.extraheader" "AUTHORIZATION: basic dGVzdA==" +expect_fail "rejects persisted checkout credentials" "persist-credentials: false" "$FIXTURE" \ + PR_ACTOR="dependabot[bot]" \ + PR_HEAD_REF="dependabot/fixture" \ + PR_HEAD_REPO="owner/repo" \ + PR_HEAD_SHA="$SHA" \ + GITHUB_REPOSITORY="owner/repo" +git -C "$FIXTURE" config --unset "http.https://github.com/.extraheader" + expect_ok "accepts valid dependabot PR head" "$FIXTURE" \ PR_ACTOR="dependabot[bot]" \ PR_HEAD_REF="dependabot/fixture" \ @@ -122,7 +190,49 @@ expect_ok "accepts valid dependabot PR head" "$FIXTURE" \ PR_HEAD_SHA="$SHA" \ GITHUB_REPOSITORY="owner/repo" -rm -rf "$no_git" +echo +echo "== push.sh tests ==" + +make_push_fixture +run_push +check "pushes fast-forward: exits 0" test "$STATUS" -eq 0 +check "pushes fast-forward: updates branch" \ + test "$(remote_head)" = "$(git -C "$CLONE" rev-parse HEAD)" +check "pushes fast-forward: reports pushed" grep -qx "result=pushed" "$OUT" + +make_push_fixture +# Simulate a Dependabot rebase: the branch moves off BASE_SHA mid-run. +moved="$(git -C "$CLONE" commit-tree -p "$BASE_SHA" -m "rebased" "$BASE_SHA^{tree}")" +git -C "$CLONE" push --quiet --force "$REMOTE_URL" "$moved:refs/heads/dependabot/fixture" +run_push +check "superseded when branch moved: exits 0" test "$STATUS" -eq 0 +check "superseded when branch moved: leaves branch" test "$(remote_head)" = "$moved" +check "superseded when branch moved: reports superseded" \ + grep -qx "result=superseded" "$OUT" + +make_push_fixture +printf '#!/bin/sh\nexit 1\n' >"$PUSH_DIR/remote.git/hooks/pre-receive" +chmod +x "$PUSH_DIR/remote.git/hooks/pre-receive" +run_push +check "fails when rejected and branch not moved: exits non-zero" \ + test "$STATUS" -ne 0 +check "fails when rejected and branch not moved: leaves branch" \ + test "$(remote_head)" = "$BASE_SHA" +check "fails when rejected and branch not moved: reports nothing" test ! -s "$OUT" + +make_push_fixture +# Config and hooks an enhance command could plant to redirect or leak the token. +marker="$PUSH_DIR/hook-ran" +printf '#!/bin/sh\ntouch "%s"\n' "$marker" >"$CLONE/.git/hooks/pre-push" +chmod +x "$CLONE/.git/hooks/pre-push" +git -C "$CLONE" config "url.file://$PUSH_DIR/evil.git.insteadOf" "$REMOTE_URL" +run_push +check "ignores planted config and hooks: exits 0" test "$STATUS" -eq 0 +check "ignores planted config and hooks: pushes to real remote" \ + test "$(remote_head)" = "$(git -C "$CLONE" rev-parse HEAD)" +check "ignores planted config and hooks: skips hooks" test ! -e "$marker" +check "ignores planted config and hooks: drops url config" \ + test -z "$(git -C "$CLONE" config --get-regexp '^url\.')" echo if [[ $FAIL -eq 0 ]]; then diff --git a/actions/enhance-dependabot/scripts/validate.sh b/actions/enhance-dependabot/scripts/validate.sh index f3287c30e..c0c498573 100755 --- a/actions/enhance-dependabot/scripts/validate.sh +++ b/actions/enhance-dependabot/scripts/validate.sh @@ -3,7 +3,9 @@ # # Fails unless the workflow was triggered by Dependabot itself, the PR head is # an in-repository 'dependabot/*' branch, and that branch's head SHA is checked -# out as HEAD (pushing a merge/base ref would corrupt the Dependabot branch). +# out as HEAD (pushing a merge/base ref would corrupt the Dependabot branch) +# with no persisted checkout credentials (they would put a write token on disk +# while the enhance command runs, and override push-token on push). # The actor check enforces the documented consumer contract (gate the workflow # on `github.actor == 'dependabot[bot]'`) even if a caller forgets it. # @@ -36,7 +38,10 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then fi head="$(git rev-parse HEAD)" if [[ "$head" != "${PR_HEAD_SHA:-}" ]]; then - fail "HEAD ($head) is not the PR head SHA (${PR_HEAD_SHA:-}). Check out 'github.event.pull_request.head.sha' with 'persist-credentials: true' before running this action; otherwise the push would carry a merge commit onto the Dependabot branch." + fail "HEAD ($head) is not the PR head SHA (${PR_HEAD_SHA:-}). Check out 'github.event.pull_request.head.sha' before running this action; otherwise the push would carry a merge commit onto the Dependabot branch." +fi +if git config --get-regexp '^http\..*\.extraheader$' >/dev/null 2>&1; then + fail "Persisted checkout credentials found (http.*.extraheader). Check out with 'persist-credentials: false': persisted credentials put a write token on disk while the enhance command runs, and override push-token on push." fi echo "Validated: dependabot PR head '${PR_HEAD_REF}' (${PR_HEAD_SHA:0:12}) checked out as HEAD."