diff --git a/.changeset/lucky-pumas-enhance.md b/.changeset/lucky-pumas-enhance.md new file mode 100644 index 000000000..73c417888 --- /dev/null +++ b/.changeset/lucky-pumas-enhance.md @@ -0,0 +1,5 @@ +--- +"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 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/CHANGELOG.md b/actions/enhance-dependabot/CHANGELOG.md new file mode 100644 index 000000000..cc35532fe --- /dev/null +++ b/actions/enhance-dependabot/CHANGELOG.md @@ -0,0 +1 @@ +# enhance-dependabot diff --git a/actions/enhance-dependabot/README.md b/actions/enhance-dependabot/README.md new file mode 100644 index 000000000..372f13c5f --- /dev/null +++ b/actions/enhance-dependabot/README.md @@ -0,0 +1,199 @@ +# 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** 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 + +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 + # 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: 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. + # 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: false + + # 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... + + # 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 + 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:** + +- 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: 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. 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 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 + +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. | +| `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 it was pushed). | + +## Behavior + +- Guards: only runs when triggered by `dependabot[bot]`, on in-repository + `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`, 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. + +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 + +- 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. +- The enhance command runs code that a dependency update can influence + (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 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 new file mode 100644 index 000000000..44ba47663 --- /dev/null +++ b/actions/enhance-dependabot/action.yml @@ -0,0 +1,296 @@ +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. + 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: + 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. + push-token: + required: false + default: ${{ github.token }} + description: | + 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: "" + 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. + +outputs: + changed: + description: + 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 it was pushed). + value: + ${{ steps.push.outputs.result == 'pushed' && steps.commit.outputs.sha || + '' }} + +runs: + using: composite + steps: + # 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 }} + VALIDATE_SCRIPT: ${{ github.action_path }}/scripts/validate.sh + run: "$VALIDATE_SCRIPT" + + - 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 }} + 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 "${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 }} + 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.push.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}\`." + 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.push.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.push.outcome == 'failure' }} + shell: bash + run: exit 1 + + - name: Comment on success + if: + ${{ 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.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) }} + run: | + { + echo "### :robot: enhance-dependabot" + echo + 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})" + 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 + if: ${{ always() }} + shell: bash + env: + ENHANCE_OUTCOME: ${{ steps.enhance.outcome }} + PUSH_OUTCOME: ${{ steps.push.outcome }} + PUSH_RESULT: ${{ steps.push.outputs.result }} + 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}" + echo "- Repository changes detected: ${CHANGED:-unknown}" + if [[ "$CHANGED" == 'true' ]]; then + echo "- Push outcome: ${PUSH_RESULT:-${PUSH_OUTCOME:-skipped}}" + fi + echo + if [[ "$ENHANCE_OUTCOME" == 'failure' || "$PUSH_OUTCOME" == 'failure' ]]; then + echo "The PR was NOT enhanced. See the PR comment for details." + 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 + if [[ -f "${RUNNER_TEMP}/commit-stat.txt" ]]; then + echo + echo "
Changed files" + echo + echo '```' + cat "${RUNNER_TEMP}/commit-stat.txt" + echo '```' + echo + echo "
" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/actions/enhance-dependabot/package.json b/actions/enhance-dependabot/package.json new file mode 100644 index 000000000..597e15e1d --- /dev/null +++ b/actions/enhance-dependabot/package.json @@ -0,0 +1,13 @@ +{ + "name": "enhance-dependabot", + "version": "0.0.0", + "description": "", + "private": true, + "scripts": { + "test": "bash scripts/test.sh" + }, + "keywords": [], + "author": "@smartcontractkit", + "license": "MIT", + "packageManager": "pnpm@10.29.3" +} 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 new file mode 100755 index 000000000..6b8e49b72 --- /dev/null +++ b/actions/enhance-dependabot/scripts/test.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# 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 +ROOT="$(mktemp -d "${TMPDIR:-/tmp}/enhance-dependabot-test.XXXXXX")" || exit 1 +OUTPUT="" + +# shellcheck disable=SC2329 # invoked via trap below +cleanup() { + 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 "$ROOT/fixture.XXXXXX")" + init_repo "$FIXTURE" + 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)" +} + +# 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. +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 "$ROOT/no-git.XXXXXX")" + +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" + +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" \ + PR_HEAD_REPO="owner/repo" \ + PR_HEAD_SHA="$SHA" \ + GITHUB_REPOSITORY="owner/repo" + +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 + 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..c0c498573 --- /dev/null +++ b/actions/enhance-dependabot/scripts/validate.sh @@ -0,0 +1,47 @@ +#!/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) +# 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. +# +# 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' 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." 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: {}