Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions .github/scripts/dependabot-auto-merge.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/** Validate that a dependency pull request remains safe to auto-merge. */
import { existsSync, readFileSync, statSync } from "node:fs";
import { isAbsolute, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";

const DEPENDABOT = "dependabot[bot]";
const WEB_FLOW = "web-flow";

function refuse(message) {
throw new Error(`Refusing auto-merge; ${message}`);
}

function dependencyEcosystem(headRef) {
if (headRef.startsWith("dependabot/uv/")) return "uv";
if (headRef.startsWith("dependabot/npm_and_yarn/")) return "npm";
if (headRef.startsWith("dependabot/github_actions/")) return "github-actions";
refuse(`unsupported Dependabot branch: ${headRef}`);
}

function isTrustedBaseFile(trustedBaseDirectory, path) {
const base = resolve(trustedBaseDirectory);
const candidate = resolve(base, path);
const pathFromBase = relative(base, candidate);
if (
pathFromBase === "" ||
isAbsolute(pathFromBase) ||
pathFromBase === ".." ||
pathFromBase.startsWith(`..${sep}`)
)
return false;
return existsSync(candidate) && statSync(candidate).isFile();
}

function assertAllowedFiles(ecosystem, changedFiles, trustedBaseDirectory) {
if (changedFiles.length === 0)
refuse("the pull request has no changed files.");
if (ecosystem === "uv") {
if (!isTrustedBaseFile(trustedBaseDirectory, "uv.lock"))
refuse("the trusted base does not use uv.");
if (changedFiles.length !== 1 || changedFiles[0] !== "uv.lock")
refuse("uv updates must change only uv.lock.");
return;
}
if (ecosystem === "npm") {
if (
!isTrustedBaseFile(trustedBaseDirectory, "package.json") ||
!isTrustedBaseFile(trustedBaseDirectory, "package-lock.json")
)
refuse("the trusted base does not use npm.");
const isPackageFile = (path) =>
path === "package.json" || path === "package-lock.json";
const isLockfileOnly =
changedFiles.length === 1 && changedFiles[0] === "package-lock.json";
const isManifestAndLockfile =
changedFiles.length === 2 &&
changedFiles.includes("package.json") &&
changedFiles.includes("package-lock.json") &&
changedFiles.every(isPackageFile);
if (!isLockfileOnly && !isManifestAndLockfile)
refuse(
"npm updates must change only package.json and package-lock.json.",
);
return;
}

const isExistingAllowedFile = (path) =>
(/^\.github\/workflows\/[^/]+\.ya?ml$/.test(path) ||
/(^|\/)action\.ya?ml$/.test(path)) &&
isTrustedBaseFile(trustedBaseDirectory, path);
if (!changedFiles.every(isExistingAllowedFile))
refuse("the update changes a file outside the trusted dependency scope.");
}

function isVerifiedDependabotCommit(commit) {
return (
commit?.author?.login === DEPENDABOT &&
commit?.committer?.login === WEB_FLOW &&
commit?.commit?.verification?.verified === true
);
}

function assertDirectDependabotHistory(event, commits) {
const [commit] = commits;
if (
commits.length !== 1 ||
!isVerifiedDependabotCommit(commit) ||
commit?.sha !== event.pull_request.head.sha
)
refuse("the pull request does not have a verified Dependabot head commit.");
}

function assertMergeParentAncestry(event, commits, ancestryProofs) {
const mergeCommits = commits.slice(1);
const currentBase = event.pull_request.base.sha;
if (
!Array.isArray(ancestryProofs) ||
ancestryProofs.length !== mergeCommits.length
)
refuse("the merge-parent ancestry evidence is incomplete.");

for (const [index, commit] of mergeCommits.entries()) {
const secondParent = commit?.parents?.[1]?.sha;
const proof = ancestryProofs[index];
if (
typeof secondParent !== "string" ||
proof?.parent_sha !== secondParent ||
proof?.base_sha !== currentBase ||
proof?.base_commit !== secondParent ||
proof?.head_commit !== currentBase ||
proof?.merge_base_commit !== secondParent ||
!["ahead", "identical"].includes(proof?.status) ||
!Number.isInteger(proof?.ahead_by) ||
proof.ahead_by < 0 ||
proof?.behind_by !== 0
)
refuse("a merge second parent is not proven to be on the current base.");
}
}

function assertUpdateBranchHistory(event, commits, ancestryProofs) {
if (commits.length < 2)
refuse("the pull request is not a GitHub Update branch merge.");
if (!isVerifiedDependabotCommit(commits[0]))
refuse("the pull request history does not begin with Dependabot.");

for (let index = 1; index < commits.length; index += 1) {
const commit = commits[index];
const previous = commits[index - 1];
if (
commit?.committer?.login !== WEB_FLOW ||
commit?.commit?.verification?.verified !== true ||
commit?.parents?.length !== 2 ||
commit.parents[0]?.sha !== previous?.sha
)
refuse("the pull request contains a non-Dependabot edit.");
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

assertMergeParentAncestry(event, commits, ancestryProofs);
const latest = commits.at(-1);
if (
latest?.sha !== event.pull_request.head.sha ||
latest.parents[1]?.sha !== event.pull_request.base.sha
)
refuse("the latest commit is not an update from the current base branch.");
}

/**
* Authorize a Dependabot update or a chain containing only GitHub Update branch merges.
*
* @param {object} input Validation inputs from the pull-request event and API.
*/
export function authorizeDependabotUpdate({
ancestryProofs = [],
changedFiles,
commits,
event,
trustedBaseDirectory = process.cwd(),
}) {
const pullRequest = event.pull_request;
if (
event.repository?.fork !== false ||
pullRequest?.user?.login !== DEPENDABOT ||
pullRequest?.head?.repo?.full_name !== event.repository?.full_name ||
pullRequest?.base?.ref !== event.repository?.default_branch
)
refuse(
"the pull request does not have the required Dependabot provenance.",
);

const ecosystem = dependencyEcosystem(pullRequest.head.ref);
if (commits.length === 1) assertDirectDependabotHistory(event, commits);
else assertUpdateBranchHistory(event, commits, ancestryProofs);
assertAllowedFiles(ecosystem, changedFiles, trustedBaseDirectory);
return ecosystem;
}

function main() {
const [, , eventPath, changedFilesPath, commitsPath, ancestryProofsPath] =
process.argv;
if (!eventPath || !changedFilesPath || !commitsPath || !ancestryProofsPath)
throw new Error(
"Usage: dependabot-auto-merge.mjs EVENT CHANGED_FILES COMMITS ANCESTRY_PROOFS",
);
const event = JSON.parse(readFileSync(eventPath, "utf8"));
const changedFiles = readFileSync(changedFilesPath, "utf8")
.split("\n")
.filter(Boolean);
const commitPages = JSON.parse(readFileSync(commitsPath, "utf8"));
const commits = commitPages.flat();
const ancestryProofs = JSON.parse(readFileSync(ancestryProofsPath, "utf8"));
authorizeDependabotUpdate({
ancestryProofs,
changedFiles,
commits,
event,
});
console.log("Authorized dependency update files and commit history.");
}

if (
process.argv[1] &&
fileURLToPath(import.meta.url) === resolve(process.argv[1])
)
main();
45 changes: 45 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:

permissions:
contents: read
pull-requests: read

concurrency:
group: node-ci-${{ github.workflow }}-${{ github.ref }}
Expand All @@ -18,7 +19,51 @@ jobs:
test:
name: Node CI
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout trusted dependency authorization helper
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.user.login == 'dependabot[bot]'
uses: actions/checkout@v7
with:
persist-credentials: false
ref: ${{ github.event.pull_request.base.sha }}

- name: Authorize Dependabot update before checking out its head
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.user.login == 'dependabot[bot]'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
changed_files="${RUNNER_TEMP}/dependabot-changed-files"
commits="${RUNNER_TEMP}/dependabot-commits.json"
ancestry_proofs="${RUNNER_TEMP}/dependabot-ancestry-proofs.json"
ancestry_proof_items="${RUNNER_TEMP}/dependabot-ancestry-proof-items.jsonl"
gh api --paginate \
"repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \
--jq '.[].filename' > "${changed_files}"
gh api --paginate --slurp \
"repos/${REPOSITORY}/pulls/${PR_NUMBER}/commits?per_page=100" \
> "${commits}"
: > "${ancestry_proof_items}"
while IFS= read -r second_parent; do
gh api "repos/${REPOSITORY}/compare/${second_parent}...${BASE_SHA}" |
jq -c --arg parent_sha "${second_parent}" --arg base_sha "${BASE_SHA}" \
'{parent_sha, base_sha, base_commit: .base_commit.sha, head_commit: .head_commit.sha, merge_base_commit: .merge_base_commit.sha, status, ahead_by, behind_by}' \
>> "${ancestry_proof_items}"
done < <(jq -r '.[].[] | select(.parents | length == 2) | .parents[1].sha' "${commits}")
jq -s . "${ancestry_proof_items}" > "${ancestry_proofs}"
node .github/scripts/dependabot-auto-merge.mjs \
"${GITHUB_EVENT_PATH}" "${changed_files}" "${commits}" "${ancestry_proofs}"

- name: Check out repository
uses: actions/checkout@v7
with:
Expand Down
86 changes: 35 additions & 51 deletions .github/workflows/dependabot-auto-merge.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Dependabot dependency auto-merge
name: Dependabot auto-merge

on:
pull_request:
Expand All @@ -9,63 +9,51 @@ concurrency:
cancel-in-progress: true

jobs:
verify-dependabot-metadata:
authorize-dependency-update:
if: >-
github.event.repository.fork == false &&
github.event.pull_request.user.login == 'dependabot[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.base.ref ==
github.event.repository.default_branch
github.event.pull_request.user.login == 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Fetch Dependabot metadata
uses: dependabot/fetch-metadata@v3
- name: Checkout trusted authorization helper
uses: actions/checkout@v7
with:
persist-credentials: false
ref: ${{ github.event.pull_request.base.sha }}

verify-changed-files:
if: >-
github.event.repository.fork == false &&
github.event.pull_request.user.login == 'dependabot[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.base.ref ==
github.event.repository.default_branch
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Verify supported dependency update
- name: Authorize dependency update files
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPOSITORY: ${{ github.repository }}
run: |
changed_files="$(gh api --paginate \
set -euo pipefail
changed_files="${RUNNER_TEMP}/dependabot-changed-files"
commits="${RUNNER_TEMP}/dependabot-commits.json"
ancestry_proofs="${RUNNER_TEMP}/dependabot-ancestry-proofs.json"
ancestry_proof_items="${RUNNER_TEMP}/dependabot-ancestry-proof-items.jsonl"
gh api --paginate \
"repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \
--jq '.[].filename')"
[[ -n "${changed_files}" ]] || {
echo "Refusing auto-merge; no changed files were reported"
exit 1
}
npm_invalid_files="$(printf '%s\n' "${changed_files}" | grep -Ev '^package(-lock)?\.json$' || true)"
if [[ -z "${npm_invalid_files}" ]] && \
printf '%s\n' "${changed_files}" | grep -Fxq 'package-lock.json'; then
exit 0
fi

actions_invalid_files="$(printf '%s\n' "${changed_files}" | grep -Ev '^(\.github/workflows/[^/]+\.ya?ml|(review/|fix/)?action\.ya?ml)$' || true)"
if [[ -z "${actions_invalid_files}" ]]; then
exit 0
fi

echo "Refusing auto-merge; changed files are neither a supported npm update nor a GitHub Actions-only update:"
echo "${changed_files}"
exit 1
--jq '.[].filename' > "${changed_files}"
gh api --paginate --slurp \
"repos/${REPOSITORY}/pulls/${PR_NUMBER}/commits?per_page=100" \
> "${commits}"
: > "${ancestry_proof_items}"
while IFS= read -r second_parent; do
gh api "repos/${REPOSITORY}/compare/${second_parent}...${BASE_SHA}" |
jq -c --arg parent_sha "${second_parent}" --arg base_sha "${BASE_SHA}" \
'{parent_sha, base_sha, base_commit: .base_commit.sha, head_commit: .head_commit.sha, merge_base_commit: .merge_base_commit.sha, status, ahead_by, behind_by}' \
>> "${ancestry_proof_items}"
done < <(jq -r '.[].[] | select(.parents | length == 2) | .parents[1].sha' "${commits}")
jq -s . "${ancestry_proof_items}" > "${ancestry_proofs}"
node .github/scripts/dependabot-auto-merge.mjs \
"${GITHUB_EVENT_PATH}" "${changed_files}" "${commits}" "${ancestry_proofs}"

enable-auto-merge:
needs: [verify-dependabot-metadata, verify-changed-files]
needs: authorize-dependency-update
runs-on: ubuntu-latest
permissions:
contents: write
Expand All @@ -76,20 +64,16 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_URL: ${{ github.event.pull_request.html_url }}
run:
gh pr merge --auto --squash --match-head-commit "${HEAD_SHA}"
"${PR_URL}"
run: gh pr merge --auto --squash --match-head-commit "${HEAD_SHA}" "${PR_URL}"

disable-auto-merge:
if: >-
always() && (needs.verify-dependabot-metadata.result != 'success' ||
needs.verify-changed-files.result != 'success') &&
failure() && !cancelled() &&
github.event.repository.fork == false &&
github.event.pull_request.user.login == 'dependabot[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.base.ref ==
github.event.repository.default_branch
needs: [verify-dependabot-metadata, verify-changed-files]
github.event.pull_request.base.ref == github.event.repository.default_branch
needs: authorize-dependency-update
runs-on: ubuntu-latest
permissions:
contents: write
Expand Down
Loading
Loading