Files
pdfme/.github/workflows/dependabot-automerge.yml

357 lines
14 KiB
YAML

name: Dependabot Auto-merge
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
concurrency:
group: dependabot-automerge-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
auto-merge:
runs-on: ubuntu-latest
if: >
github.event.pull_request.user.login == 'dependabot[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Fetch Dependabot metadata
id: dependabot-metadata
uses: dependabot/fetch-metadata@v2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Check update type
id: check-update
run: |
echo "Update type: ${{ steps.dependabot-metadata.outputs.update-type }}"
if [[ "${{ steps.dependabot-metadata.outputs.update-type }}" == "version-update:semver-minor" ]] || \
[[ "${{ steps.dependabot-metadata.outputs.update-type }}" == "version-update:semver-patch" ]]; then
echo "should-merge=true" >> $GITHUB_OUTPUT
echo "Auto-merge approved for ${{ steps.dependabot-metadata.outputs.update-type }}"
else
echo "should-merge=false" >> $GITHUB_OUTPUT
echo "Skipping auto-merge for ${{ steps.dependabot-metadata.outputs.update-type }}"
fi
# Re-checks transitive deps in lockfile that Dependabot's cooldown does not cover.
- name: Check npm release age
id: release-age
if: steps.check-update.outputs.should-merge == 'true' && steps.dependabot-metadata.outputs.package-ecosystem == 'npm'
env:
MINIMUM_RELEASE_AGE_DAYS: "3"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
UPDATED_DEPENDENCIES_JSON: ${{ steps.dependabot-metadata.outputs.updated-dependencies-json }}
run: |
node <<'NODE'
const fs = require('node:fs');
const githubToken = process.env.GH_TOKEN;
const repository = process.env.REPOSITORY;
const prNumber = process.env.PR_NUMBER;
const baseSha = process.env.BASE_SHA;
const headSha = process.env.HEAD_SHA;
const minAgeDays = Number(process.env.MINIMUM_RELEASE_AGE_DAYS);
if (!Number.isFinite(minAgeDays) || minAgeDays < 0) {
throw new Error(`Invalid MINIMUM_RELEASE_AGE_DAYS: ${process.env.MINIMUM_RELEASE_AGE_DAYS}`);
}
const minAgeMs = minAgeDays * 24 * 60 * 60 * 1000;
const updatedDependencies = JSON.parse(process.env.UPDATED_DEPENDENCIES_JSON || '[]');
const candidates = new Map();
const registryCache = new Map();
const tooNew = [];
const errors = [];
const writeOutput = (name, value, multiline = false) => {
if (!multiline) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
return;
}
const delimiter = `EOF_${Date.now()}_${Math.random().toString(16).slice(2)}`;
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
};
const addCandidate = (name, version, source) => {
if (!name || !version) {
errors.push(`${name || '(unknown)'}@${version || '(unknown)'}`);
return;
}
const key = `${name}@${version}`;
const candidate = candidates.get(key) || { name, version, sources: new Set() };
candidate.sources.add(source);
candidates.set(key, candidate);
};
const githubFetchJson = async (path, allowNotFound = false) => {
const response = await fetch(`https://api.github.com${path}`, {
headers: {
accept: 'application/vnd.github+json',
authorization: `Bearer ${githubToken}`,
'user-agent': 'pdfme-dependabot-automerge',
'x-github-api-version': '2022-11-28',
},
});
if (allowNotFound && response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`GitHub API returned ${response.status} for ${path}`);
}
return response.json();
};
const listPullRequestFiles = async () => {
const files = [];
let page = 1;
while (true) {
const pageFiles = await githubFetchJson(
`/repos/${repository}/pulls/${prNumber}/files?per_page=100&page=${page}`,
);
files.push(...pageFiles);
if (pageFiles.length < 100) {
return files;
}
page += 1;
}
};
const decodeBase64 = (content) => Buffer.from(content.replace(/\n/g, ''), 'base64').toString('utf8');
const readRepoFile = async (filePath, ref) => {
const encodedPath = filePath.split('/').map(encodeURIComponent).join('/');
const content = await githubFetchJson(
`/repos/${repository}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`,
true,
);
if (!content) {
return null;
}
if (content.type !== 'file') {
throw new Error(`${filePath} at ${ref} is not a file`);
}
if (content.content) {
return decodeBase64(content.content);
}
const blob = await githubFetchJson(`/repos/${repository}/git/blobs/${content.sha}`);
return decodeBase64(blob.content);
};
const packageNameFromLockPath = (lockPath) => {
const parts = lockPath.split('/');
const nodeModulesIndex = parts.lastIndexOf('node_modules');
if (nodeModulesIndex === -1) {
return null;
}
const packageParts = parts.slice(nodeModulesIndex + 1);
if (packageParts[0]?.startsWith('@')) {
return packageParts.length >= 2 ? `${packageParts[0]}/${packageParts[1]}` : null;
}
return packageParts[0] || null;
};
const collectLockPackages = (lockText, lockfilePath) => {
const lock = JSON.parse(lockText);
const packages = new Map();
for (const [lockPath, packageMeta] of Object.entries(lock.packages || {})) {
if (!packageMeta || packageMeta.link || !packageMeta.version) {
continue;
}
const packageName = packageMeta.name || packageNameFromLockPath(lockPath);
if (!packageName) {
continue;
}
if (packageMeta.resolved && /^(file|link|workspace):/.test(packageMeta.resolved)) {
continue;
}
const key = `${packageName}@${packageMeta.version}`;
const entry = packages.get(key) || {
name: packageName,
version: packageMeta.version,
paths: [],
};
entry.paths.push(`${lockfilePath}:${lockPath}`);
packages.set(key, entry);
}
return packages;
};
const getPublishedAt = async (dependencyName, version) => {
let metadata = registryCache.get(dependencyName);
if (!metadata) {
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(dependencyName)}`);
if (!response.ok) {
throw new Error(`npm registry returned ${response.status} for ${dependencyName}`);
}
metadata = await response.json();
registryCache.set(dependencyName, metadata);
}
const publishedAt = metadata.time?.[version];
if (!publishedAt) {
throw new Error(`publish time not found for ${dependencyName}@${version}`);
}
return new Date(publishedAt);
};
const checkCandidate = async (candidate) => {
try {
const publishedAt = await getPublishedAt(candidate.name, candidate.version);
const ageMs = Date.now() - publishedAt.getTime();
if (ageMs < minAgeMs) {
const ageHours = Math.max(0, Math.floor(ageMs / (60 * 60 * 1000)));
tooNew.push(
`${candidate.name}@${candidate.version} (${ageHours}h old; ${[...candidate.sources].join(', ')})`,
);
}
} catch (error) {
errors.push(`${candidate.name}@${candidate.version}: ${error.message}`);
}
};
(async () => {
for (const dependency of updatedDependencies) {
if (dependency.packageEcosystem !== 'npm') {
continue;
}
addCandidate(dependency.dependencyName, dependency.newVersion, 'Dependabot metadata');
}
const prFiles = await listPullRequestFiles();
const lockfiles = prFiles
.filter((file) => file.status !== 'removed' && file.filename.endsWith('package-lock.json'))
.map((file) => file.filename);
for (const lockfilePath of lockfiles) {
const [baseLockText, headLockText] = await Promise.all([
readRepoFile(lockfilePath, baseSha),
readRepoFile(lockfilePath, headSha),
]);
if (!headLockText) {
continue;
}
const basePackages = baseLockText ? collectLockPackages(baseLockText, lockfilePath) : new Map();
const headPackages = collectLockPackages(headLockText, lockfilePath);
for (const [key, packageEntry] of headPackages) {
if (!basePackages.has(key)) {
addCandidate(packageEntry.name, packageEntry.version, lockfilePath);
}
}
}
const candidateList = [...candidates.values()];
let index = 0;
const workerCount = Math.min(8, candidateList.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (index < candidateList.length) {
const candidate = candidateList[index];
index += 1;
await checkCandidate(candidate);
}
}),
);
if (tooNew.length > 0 || errors.length > 0) {
const outputLimit = 50;
const tooNewOutput = tooNew.slice(0, outputLimit);
const errorsOutput = errors.slice(0, outputLimit - tooNewOutput.length);
const omittedCount = tooNew.length + errors.length - tooNewOutput.length - errorsOutput.length;
const sections = [];
if (tooNewOutput.length > 0) {
sections.push(`Fresh npm releases:\n${tooNewOutput.map((item) => `- ${item}`).join('\n')}`);
}
if (errorsOutput.length > 0) {
sections.push(`Registry lookup failures:\n${errorsOutput.map((item) => `- ${item}`).join('\n')}`);
}
if (omittedCount > 0) {
sections.push(`...and ${omittedCount} more entries.`);
}
const blocked = sections.join('\n\n');
console.log(`Auto-merge blocked: ${blocked}`);
writeOutput('passed', 'false');
writeOutput('blocked-releases', blocked, true);
return;
}
console.log(`Checked ${candidateList.length} npm package versions; all are at least ${minAgeDays} days old.`);
writeOutput('passed', 'true');
})().catch((error) => {
console.error(error);
writeOutput('passed', 'false');
writeOutput('blocked-releases', error.message, true);
});
NODE
- name: Enable auto-merge
if: >
steps.check-update.outputs.should-merge == 'true' &&
(steps.dependabot-metadata.outputs.package-ecosystem != 'npm' ||
steps.release-age.outputs.passed == 'true')
run: |
gh pr merge --auto --squash "$PR_URL"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
- name: Comment on PR
if: >
steps.check-update.outputs.should-merge == 'true' &&
(steps.dependabot-metadata.outputs.package-ecosystem != 'npm' ||
steps.release-age.outputs.passed == 'true')
run: |
body_file="$(mktemp)"
printf '%s\n' "Auto-merge enabled for this ${UPDATE_TYPE} update. Will merge automatically once CI checks pass." > "$body_file"
gh pr comment "$PR_URL" --body-file "$body_file"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
UPDATE_TYPE: ${{ steps.dependabot-metadata.outputs.update-type }}
- name: Comment when release is too new
if: steps.check-update.outputs.should-merge == 'true' && steps.dependabot-metadata.outputs.package-ecosystem == 'npm' && steps.release-age.outputs.passed == 'false'
run: |
body_file="$(mktemp)"
{
printf '%s\n\n' "Auto-merge skipped because at least one npm package version is newer than 3 days, or its publish time could not be verified."
printf '%s\n' "$BLOCKED_RELEASES"
} > "$body_file"
gh pr comment "$PR_URL" --body-file "$body_file"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
BLOCKED_RELEASES: ${{ steps.release-age.outputs.blocked-releases }}