mirror of
https://github.com/pdfme/pdfme.git
synced 2026-06-16 10:19:20 -04:00
357 lines
14 KiB
YAML
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 }}
|