Skip to content
Open
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
130 changes: 122 additions & 8 deletions .github/actions/automerge-dependabot/gate.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,95 @@ function parseJson(text) {
}
}

// Where the standards sync places the org dependabot-policy in a repository.
const POLICY_PATH = ".github/standards/dependabot-policy/policy.json";
// pr-automerge-dependabot.yml's publisher-allowlist default. A caller that
// passes any other list keeps it whatever the policy says.
const DEFAULT_PUBLISHER_ALLOWLIST = ["actions/*", "github/*", "anthropics/*"];

const isObject = (value) =>
value !== null && typeof value === "object" && !Array.isArray(value);
const isGlobList = (value) =>
Array.isArray(value) &&
value.every((entry) => typeof entry === "string" && entry.length > 0);

// fetch-metadata's id for each Dependabot package-ecosystem name the policy uses.
const METADATA_ECOSYSTEM = {
"github-actions": ACTIONS_ECOSYSTEM,
npm: "npm_and_yarn",
pip: "pip",
uv: "uv",
nuget: "nuget",
};

// The gate's inputs once the synced policy (its text, undefined when the
// repository has no copy) is applied to the caller's. With no copy the inputs
// stand. With one, its autoMerge.reviewOnly entries for `repository` join the
// caller's review-only names and its autoMerge.ecosystems bound the
// ecosystems armed; both only narrow. When the caller leaves the
// publisher-allowlist at its default, the list becomes the policy's
// `tier: auto` publishers plus its GitHub Actions and own fast track, whose
// patch and minor bumps arm like any other allowlisted publisher's.
// Returns { inputs } or { failure }.
function resolvePolicyInputs(inputs, policyText, repository) {
if (policyText === undefined) return { inputs };
const policy = parseJson(policyText);
const autoMerge = policy?.autoMerge;
const publishers = autoMerge?.publishers;
const ecosystems = autoMerge?.ecosystems;
const policyReviewOnly = autoMerge?.reviewOnly ?? [];
const fastTrack = policy?.fastTrack ?? {};
const tracks = [fastTrack["github-actions"] ?? [], fastTrack.own ?? []];
if (
!isObject(publishers) ||
!isGlobList(ecosystems) ||
!Array.isArray(policyReviewOnly) ||
!policyReviewOnly.every(
(entry) =>
isObject(entry) &&
typeof entry.repository === "string" &&
typeof entry.dependency === "string",
) ||
!isObject(fastTrack) ||
!tracks.every(isGlobList)
) {
return {
failure: `${POLICY_PATH} is not a readable dependabot policy (autoMerge.publishers, ecosystems, reviewOnly, fastTrack)`,
};
}
const resolved = {
...inputs,
ecosystems: ecosystems.map((name) => METADATA_ECOSYSTEM[name] ?? name),
};
const callerReviewOnly = parseJson(inputs.reviewOnly);
if (Array.isArray(callerReviewOnly)) {
const scoped = policyReviewOnly
.filter(
(entry) => entry.repository.toLowerCase() === repository.toLowerCase(),
)
.map((entry) => entry.dependency);
resolved.reviewOnly = JSON.stringify([
...new Set([...callerReviewOnly, ...scoped]),
]);
}
const input = parseJson(inputs.publisherAllowlist);
if (JSON.stringify(input) === JSON.stringify(DEFAULT_PUBLISHER_ALLOWLIST)) {
const auto = Object.keys(publishers).filter(
(publisher) => publishers[publisher]?.tier === "auto",
);
resolved.publisherAllowlist = JSON.stringify([
...new Set([...auto, ...tracks.flat()]),
]);
}
return { inputs: resolved };
}

// `pr` is the REST pull request (user.id, title, body, head.sha). `updates`
// is fetch-metadata's updated-dependencies shape. `listCommits` returns the
// REST commit list, read only once every cheaper gate holds. `inputs` holds
// the caller's raw strings: publisherAllowlist and reviewOnly (JSON arrays)
// and packageAutoMerge ("true" or anything else).
// and packageAutoMerge ("true" or anything else), plus, from a synced policy,
// ecosystems: the fetch-metadata ecosystem ids that may arm at all.
async function gateFailure({ pr, updates, listCommits, inputs }) {
if (pr?.user?.id !== DEPENDABOT_ID) return "the PR author is not Dependabot";
if (!Array.isArray(updates) || updates.length === 0)
Expand All @@ -107,15 +191,16 @@ async function gateFailure({ pr, updates, listCommits, inputs }) {
for (const update of updates) {
const name = String(update?.dependencyName ?? "");
const ecosystem = update?.packageEcosystem;
if (inputs.ecosystems && !inputs.ecosystems.includes(ecosystem))
return `${name} is a ${ecosystem} update, which the policy's autoMerge.ecosystems leaves out`;
if (reviewOnly.includes(name)) return `${name} is a review-only dependency`;
if (ecosystem === ACTIONS_ECOSYSTEM) {
if (!allowlist.some((pattern) => pattern.test(name)))
return `${name} is not an allowlisted publisher`;
} else if (!PACKAGE_ECOSYSTEMS.has(ecosystem)) {
return `${name} is not a GitHub Actions, npm, pip, uv or NuGet update`;
} else if (!packageAutoMerge) {
return `${name} is a package update and this repository turned package auto-merge off`;
} else if (reviewOnly.includes(name)) {
return `${name} is a review-only dependency`;
}
if (!UPDATE_TYPES.has(update?.updateType))
return `${name} is not a patch or minor update`;
Expand Down Expand Up @@ -247,6 +332,18 @@ async function runEvent({ github, context, core, env }) {
return "fetching the Dependabot metadata failed";
const updates = parseJson(env.UPDATED_DEPENDENCIES_JSON || "");
if (updates === undefined) return "the Dependabot metadata is not valid JSON";
const policy = await readBasePolicy();
if (policy.failure) return policy.failure;
const resolved = resolvePolicyInputs(
{
publisherAllowlist: env.PUBLISHER_ALLOWLIST,
packageAutoMerge: env.PACKAGE_AUTO_MERGE,
reviewOnly: env.REVIEW_ONLY_DEPENDENCIES,
},
policy.text,
`${context.repo.owner}/${context.repo.repo}`,
);
if (resolved.failure) return resolved.failure;
return gateFailure({
pr,
updates,
Expand All @@ -256,14 +353,28 @@ async function runEvent({ github, context, core, env }) {
pull_number: pr.number,
per_page: 100,
}),
inputs: {
publisherAllowlist: env.PUBLISHER_ALLOWLIST,
packageAutoMerge: env.PACKAGE_AUTO_MERGE,
reviewOnly: env.REVIEW_ONLY_DEPENDENCIES,
},
inputs: resolved.inputs,
});
}

// Read at the base commit, so the pull request cannot change its own tier.
async function readBasePolicy() {
try {
const { data } = await github.rest.repos.getContent({
...context.repo,
path: POLICY_PATH,
ref: pr.base.sha,
mediaType: { format: "raw" },
});
return { text: String(data) };
} catch (error) {
if (error.status === 404) return { text: undefined };
return {
failure: `${POLICY_PATH} could not be read at the base: ${error.message}`,
};
}
}

const reason = await eventFailure();
if (reason) {
core.notice(`Auto-merge not armed: ${reason}.`);
Expand All @@ -286,9 +397,12 @@ async function runEvent({ github, context, core, env }) {
}

module.exports = {
DEFAULT_PUBLISHER_ALLOWLIST,
DEPENDABOT_ID,
POLICY_PATH,
WEB_FLOW_ID,
gateFailure,
parseCommitMetadata,
resolvePolicyInputs,
runEvent,
};
70 changes: 59 additions & 11 deletions .github/actions/automerge-dependabot/queue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,42 @@
// Nothing here throws: one pull request's failure is a warning and the next
// one is still tried, so this job never blocks anything.

const { DEPENDABOT_ID, gateFailure, parseCommitMetadata } = require(
"./gate.cjs",
);
const fs = require("node:fs");
const path = require("node:path");
const {
DEPENDABOT_ID,
POLICY_PATH,
gateFailure,
parseCommitMetadata,
resolvePolicyInputs,
} = require("./gate.cjs");

// The synced policy in the checked-out default branch: { text }, with text
// undefined when the repository has no copy, or { failure }.
function readWorkspacePolicy(workspace) {
try {
return { text: fs.readFileSync(path.join(workspace, POLICY_PATH), "utf8") };
} catch (error) {
if (error.code === "ENOENT") return { text: undefined };
return { failure: `${POLICY_PATH} could not be read: ${error.message}` };
}
}

// Every non-dev package an npm lockfile installs, nested and scoped ones
// included: { names }, {} when no lockfile is named, or { failure } when the
// named one cannot be read, so a missing runtime list never arms anything.
function readLockedRuntime(workspace, lockfile) {
if (!lockfile) return {};
try {
const { packages } = JSON.parse(fs.readFileSync(path.join(workspace, lockfile), "utf8"));
const names = Object.entries(packages)
.filter(([key, entry]) => key && !entry.dev)
.map(([key]) => key.split("node_modules/").pop());
return { names };
} catch (error) {
return { failure: `${lockfile} is not a readable npm lockfile: ${error.message}` };
}
}

const CI_STATUS = "ci-status";
const MISSING_WORKFLOWS = /without `?workflows`? permission/iu;
Expand Down Expand Up @@ -63,18 +96,21 @@ function warnArmFailure(core, pr, error) {
}

// Returns what happened to one pull request: queued, skipped or failed.
async function queueOne({ github, app, repo, core, pr, inputs, appSlug }) {
async function queueOne({ github, app, repo, core, pr, inputs, policyFailure, appSlug }) {
const disarm = () => app.graphql(DISARM, { id: pr.node_id });
const commits = await github.paginate(github.rest.pulls.listCommits, {
...repo,
pull_number: pr.number,
per_page: 100,
});
const updates = parseCommitMetadata(commits.at(-1)?.commit?.message, pr.head.ref);
const reason =
updates === null
? "its head commit carries no readable Dependabot metadata"
: await gateFailure({ pr, updates, listCommits: async () => commits, inputs });
let reason = policyFailure;
if (!reason) {
reason =
updates === null
? "its head commit carries no readable Dependabot metadata"
: await gateFailure({ pr, updates, listCommits: async () => commits, inputs });
}
if (reason) {
core.notice(`#${pr.number} left alone: ${reason}.`);
// GitHub keeps auto-merge on when a user with write access pushes to
Expand Down Expand Up @@ -128,11 +164,21 @@ async function queueOne({ github, app, repo, core, pr, inputs, appSlug }) {

async function runQueue({ github, app, context, core, env }) {
const repo = context.repo;
const inputs = {
const policy = readWorkspacePolicy(env.GITHUB_WORKSPACE);
// REVIEW_ONLY_LOCKFILE, when set, holds that lockfile's whole runtime tree
// for review in place of REVIEW_ONLY_DEPENDENCIES.
const locked = readLockedRuntime(env.GITHUB_WORKSPACE, env.REVIEW_ONLY_LOCKFILE);
const callerInputs = {
publisherAllowlist: env.PUBLISHER_ALLOWLIST,
packageAutoMerge: env.PACKAGE_AUTO_MERGE,
reviewOnly: env.REVIEW_ONLY_DEPENDENCIES,
reviewOnly: locked.names ? JSON.stringify(locked.names) : env.REVIEW_ONLY_DEPENDENCIES,
};
const readFailure = policy.failure ?? locked.failure;
const resolved = readFailure
? { failure: readFailure }
: resolvePolicyInputs(callerInputs, policy.text, `${repo.owner}/${repo.repo}`);
const policyFailure = resolved.failure ?? "";
const inputs = resolved.inputs ?? callerInputs;
const counts = { queued: 0, skipped: 0, failed: 0 };
let pulls;
try {
Expand All @@ -147,7 +193,9 @@ async function runQueue({ github, app, context, core, env }) {
}
for (const pr of pulls.filter((pull) => pull.user?.id === DEPENDABOT_ID)) {
try {
counts[await queueOne({ github, app, repo, core, pr, inputs, appSlug: env.APP_SLUG })] += 1;
counts[
await queueOne({ github, app, repo, core, pr, inputs, policyFailure, appSlug: env.APP_SLUG })
] += 1;
} catch (error) {
core.warning(`Could not check #${pr.number}: ${error.message}`);
counts.failed += 1;
Expand Down
8 changes: 8 additions & 0 deletions .github/actions/bump-versions/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ inputs:
description: Policy file, relative to the repository root (command prepare).
required: false
default: .github/version-bump/policy.yaml
dependabot-policy:
description: >-
The standards dependabot-policy policy.json the standards sync places in the repository,
relative to its root. Its cooldownDays apply when the policy file sets none (command
prepare); a policy file that sets cooldownDays keeps its own.
required: false
default: .github/standards/dependabot-policy/policy.json
out:
description: Directory for results.json and the prepared bumps, relative to the repository root.
required: false
Expand Down Expand Up @@ -69,6 +76,7 @@ runs:
GH_TOKEN: ${{ inputs.token }}
BUMP_COMMAND: ${{ inputs.command }}
BUMP_POLICY: ${{ inputs.policy }}
BUMP_DEPENDABOT_POLICY: ${{ inputs.dependabot-policy }}
BUMP_OUT: ${{ inputs.out }}
BUMP_ONLY: ${{ inputs.only }}
BUMP_DRY_RUN: ${{ inputs.dry-run }}
Expand Down
Loading
Loading