snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
This commit is contained in:
881
.github/scripts/check-contribution-policy.js
vendored
Normal file
881
.github/scripts/check-contribution-policy.js
vendored
Normal file
@@ -0,0 +1,881 @@
|
||||
const fs = require("node:fs");
|
||||
|
||||
const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";
|
||||
|
||||
const MAINTAINER_LOGINS = new Set(["gschier"]);
|
||||
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
||||
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
|
||||
const REVIEWER_LOGIN = "gschier";
|
||||
|
||||
const LARGE_DIFF_CHANGED_FILES = 20;
|
||||
const LARGE_DIFF_CHANGED_LINES = 800;
|
||||
const SUMMARY_TITLE_MAX_LENGTH = 80;
|
||||
const MIN_AUTOMATIC_PR_NUMBER = 494;
|
||||
|
||||
const LABELS = {
|
||||
inScope: {
|
||||
name: "contribution: in scope",
|
||||
color: "0E8A16",
|
||||
description: "Community PR appears to be in scope for maintainer review.",
|
||||
},
|
||||
outOfScope: {
|
||||
name: "contribution: out of scope",
|
||||
color: "B60205",
|
||||
description: "Community PR does not match Yaak's contribution policy.",
|
||||
},
|
||||
explicitPermission: {
|
||||
name: "contribution: explicit permission",
|
||||
color: "5319E7",
|
||||
description:
|
||||
"Community PR links feedback where @gschier explicitly allowed the work.",
|
||||
},
|
||||
missingTemplate: {
|
||||
name: "contribution: missing template",
|
||||
color: "D93F0B",
|
||||
description:
|
||||
"Community PR is missing enough of the pull request template to review.",
|
||||
},
|
||||
policyUnmet: {
|
||||
name: "contribution: policy unmet",
|
||||
color: "B60205",
|
||||
description:
|
||||
"Community PR does not currently satisfy the contribution policy.",
|
||||
},
|
||||
needsScopeReview: {
|
||||
name: "contribution: needs scope review",
|
||||
color: "FBCA04",
|
||||
description:
|
||||
"Community PR may be broader than Yaak's bug-fix contribution policy.",
|
||||
},
|
||||
};
|
||||
|
||||
const MANAGED_LABEL_NAMES = [
|
||||
...new Set(Object.values(LABELS).map((label) => label.name)),
|
||||
];
|
||||
|
||||
// Each checkbox lists its current label first, followed by legacy labels still
|
||||
// accepted from PRs opened against older versions of the template.
|
||||
const CHECKBOXES = {
|
||||
bugFix: ["This PR is a bug fix."],
|
||||
explicitPermission: [
|
||||
"If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
|
||||
],
|
||||
readContributing: [
|
||||
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
|
||||
],
|
||||
testedLocally: ["I tested this change locally."],
|
||||
testsUpdated: [
|
||||
"I added or updated tests, or tests are not reasonable for this change.",
|
||||
"I added or updated tests when reasonable.",
|
||||
],
|
||||
screenshotsAdded: [
|
||||
"I added screenshots or recordings, or this change does not affect the UI.",
|
||||
"I added screenshots or recordings for UI changes when reasonable.",
|
||||
],
|
||||
};
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function normalizeBody(body) {
|
||||
return (body || "").replace(/\r\n/g, "\n");
|
||||
}
|
||||
|
||||
function stripComments(value) {
|
||||
return value.replace(/<!--[\s\S]*?-->/g, "").trim();
|
||||
}
|
||||
|
||||
function getSection(body, heading) {
|
||||
const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, "gim");
|
||||
const match = pattern.exec(body);
|
||||
|
||||
if (match == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rest = body.slice(match.index + match[0].length);
|
||||
const nextHeadingIndex = rest.search(/^##\s+/m);
|
||||
return nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex);
|
||||
}
|
||||
|
||||
function hasMeaningfulText(value) {
|
||||
return stripComments(value || "").length > 0;
|
||||
}
|
||||
|
||||
function normalizeCheckboxLabel(label) {
|
||||
return label
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/`/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function checkboxState(body, labels) {
|
||||
const expectedLabels = new Set(labels.map(normalizeCheckboxLabel));
|
||||
|
||||
for (const line of body.split("\n")) {
|
||||
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
|
||||
|
||||
if (match == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) {
|
||||
return match[1].toLowerCase() === "x";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFeedbackUrl(body) {
|
||||
return (
|
||||
body.match(
|
||||
/https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i,
|
||||
)?.[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getLabelNames(pr) {
|
||||
return new Set((pr.labels || []).map((label) => label.name));
|
||||
}
|
||||
|
||||
function analyzePullRequest(pr) {
|
||||
const body = normalizeBody(pr.body);
|
||||
const labelNames = getLabelNames(pr);
|
||||
const states = Object.fromEntries(
|
||||
Object.entries(CHECKBOXES).map(([key, label]) => [
|
||||
key,
|
||||
checkboxState(body, label),
|
||||
]),
|
||||
);
|
||||
const sectionCount = ["Summary", "Submission", "Related"].filter(
|
||||
(heading) => getSection(body, heading) != null,
|
||||
).length;
|
||||
const checkboxCount = Object.values(states).filter(
|
||||
(state) => state != null,
|
||||
).length;
|
||||
const templateUsed = sectionCount >= 2 && checkboxCount >= 3;
|
||||
const blockers = [];
|
||||
const totalChangedLines =
|
||||
Number(pr.additions || 0) + Number(pr.deletions || 0);
|
||||
const changedFiles = Number(pr.changed_files || 0);
|
||||
const largeDiff =
|
||||
changedFiles > LARGE_DIFF_CHANGED_FILES ||
|
||||
totalChangedLines > LARGE_DIFF_CHANGED_LINES;
|
||||
|
||||
if (labelNames.has(LABELS.outOfScope.name)) {
|
||||
return {
|
||||
blockers: [
|
||||
{
|
||||
label: LABELS.outOfScope.name,
|
||||
message: "Marked out of scope by maintainer label.",
|
||||
},
|
||||
],
|
||||
changedFiles,
|
||||
desiredLabels: [LABELS.outOfScope.name],
|
||||
largeDiff,
|
||||
status: "out_of_scope",
|
||||
templateUsed,
|
||||
totalChangedLines,
|
||||
};
|
||||
}
|
||||
|
||||
if (labelNames.has(LABELS.inScope.name)) {
|
||||
return {
|
||||
blockers: [],
|
||||
changedFiles,
|
||||
desiredLabels: [LABELS.inScope.name],
|
||||
largeDiff,
|
||||
status: "in_scope",
|
||||
templateUsed,
|
||||
totalChangedLines,
|
||||
};
|
||||
}
|
||||
|
||||
if (labelNames.has(LABELS.explicitPermission.name)) {
|
||||
return {
|
||||
blockers: [],
|
||||
changedFiles,
|
||||
desiredLabels: [LABELS.explicitPermission.name],
|
||||
largeDiff,
|
||||
status: "in_scope",
|
||||
templateUsed,
|
||||
totalChangedLines,
|
||||
};
|
||||
}
|
||||
|
||||
if (!templateUsed) {
|
||||
blockers.push({
|
||||
label: LABELS.missingTemplate.name,
|
||||
message:
|
||||
"Update the PR description with the repository pull request template.",
|
||||
});
|
||||
} else {
|
||||
const summary = getSection(body, "Summary");
|
||||
const hasSummary = hasMeaningfulText(summary);
|
||||
const feedbackUrl = findFeedbackUrl(body);
|
||||
const bugFix = states.bugFix === true;
|
||||
const explicitPermission = states.explicitPermission === true;
|
||||
|
||||
if (!hasSummary) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Add a short summary describing the bug fix or permitted change.",
|
||||
});
|
||||
}
|
||||
|
||||
if (bugFix && explicitPermission) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Choose either the bug-fix checkbox or the explicit-permission checkbox, not both.",
|
||||
});
|
||||
} else if (!bugFix && !explicitPermission) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Check whether this is a bug fix, or confirm that explicit permission from @gschier is linked.",
|
||||
});
|
||||
} else if (explicitPermission && feedbackUrl == null) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Link the feedback item where @gschier explicitly gave you permission to work on this.",
|
||||
});
|
||||
}
|
||||
|
||||
if (states.readContributing !== true) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message: "Confirm that `CONTRIBUTING.md` was read and followed.",
|
||||
});
|
||||
}
|
||||
|
||||
if (states.testedLocally !== true) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message: "Confirm that the change was tested locally.",
|
||||
});
|
||||
}
|
||||
|
||||
if (states.testsUpdated !== true) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Confirm that tests were added or updated, or that tests are not reasonable for this change. Check the box either way.",
|
||||
});
|
||||
}
|
||||
|
||||
if (states.screenshotsAdded !== true) {
|
||||
blockers.push({
|
||||
label: LABELS.policyUnmet.name,
|
||||
message:
|
||||
"Confirm that screenshots or recordings were added, or that this change does not affect the UI. Check the box either way.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const desiredLabels = new Set();
|
||||
|
||||
if (blockers.length === 0) {
|
||||
desiredLabels.add(
|
||||
largeDiff
|
||||
? LABELS.needsScopeReview.name
|
||||
: states.explicitPermission
|
||||
? LABELS.explicitPermission.name
|
||||
: LABELS.inScope.name,
|
||||
);
|
||||
} else if (
|
||||
blockers.some((blocker) => blocker.label === LABELS.missingTemplate.name)
|
||||
) {
|
||||
desiredLabels.add(LABELS.missingTemplate.name);
|
||||
} else {
|
||||
desiredLabels.add(LABELS.policyUnmet.name);
|
||||
}
|
||||
|
||||
return {
|
||||
blockers,
|
||||
changedFiles,
|
||||
desiredLabels: [...desiredLabels],
|
||||
largeDiff,
|
||||
status: blockers.length === 0 ? "in_scope" : "blocked",
|
||||
templateUsed,
|
||||
totalChangedLines,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBlockingComment(analysis) {
|
||||
const lines = [
|
||||
COMMENT_MARKER,
|
||||
"Thanks for the PR. Yaak currently accepts community PRs for bug fixes, plus larger changes that link a feedback item where @gschier explicitly gave permission to work on it.",
|
||||
"",
|
||||
"This PR cannot be accepted yet because the following contribution policy requirements were unmet:",
|
||||
"",
|
||||
...analysis.blockers.map((blocker) => `- ${blocker.message}`),
|
||||
];
|
||||
|
||||
if (!analysis.templateUsed) {
|
||||
lines.push(
|
||||
"",
|
||||
"You can copy this template into the PR description and keep any existing context that is still useful.",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary>PR description template</summary>",
|
||||
"",
|
||||
"```md",
|
||||
getPullRequestTemplate(),
|
||||
"```",
|
||||
"",
|
||||
"</details>",
|
||||
);
|
||||
}
|
||||
|
||||
if (analysis.largeDiff) {
|
||||
lines.push(
|
||||
"",
|
||||
`This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as needing scope review. That label is advisory, but maintainers may ask for the scope to be reduced.`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function getPullRequestTemplate() {
|
||||
return fs.readFileSync(".github/pull_request_template.md", "utf8").trim();
|
||||
}
|
||||
|
||||
function buildInScopeComment() {
|
||||
return [
|
||||
COMMENT_MARKER,
|
||||
"Thanks for the PR. This appears to match Yaak's contribution policy and is awaiting review by @gschier.",
|
||||
"",
|
||||
"This only means the PR is in scope for review. It does not mean the change has been reviewed or accepted for merge.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildOutOfScopeComment() {
|
||||
return [
|
||||
COMMENT_MARKER,
|
||||
"Thanks for the PR. This does not appear to match Yaak's current contribution policy.",
|
||||
"",
|
||||
"Yaak currently accepts community PRs for bug fixes, or changes tied to a feedback item where @gschier explicitly gave permission to work on it.",
|
||||
"",
|
||||
"If this PR is tied to a feedback item where @gschier explicitly gave permission, please link it in the PR description.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildPolicyComment(analysis) {
|
||||
if (analysis.status === "out_of_scope") {
|
||||
return buildOutOfScopeComment();
|
||||
}
|
||||
|
||||
if (analysis.blockers.length > 0) {
|
||||
return buildBlockingComment(analysis);
|
||||
}
|
||||
|
||||
return buildInScopeComment();
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function truncateTitle(title) {
|
||||
if (title.length <= SUMMARY_TITLE_MAX_LENGTH) {
|
||||
return title;
|
||||
}
|
||||
|
||||
return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function escapeTableText(value) {
|
||||
return escapeHtml(value).replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function summarizeResult({ pr, analysis, skipped, skipReason }) {
|
||||
const comment =
|
||||
analysis == null
|
||||
? "None"
|
||||
: buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
|
||||
const summary = {
|
||||
blocked: analysis?.blockers.length > 0,
|
||||
comment,
|
||||
details: "None",
|
||||
labels:
|
||||
analysis?.desiredLabels.length > 0
|
||||
? analysis.desiredLabels.join(", ")
|
||||
: "None",
|
||||
number: pr.number,
|
||||
prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`,
|
||||
status: "In scope",
|
||||
title: escapeHtml(truncateTitle(pr.title)),
|
||||
};
|
||||
|
||||
if (skipped) {
|
||||
return {
|
||||
...summary,
|
||||
blocked: false,
|
||||
comment: "None",
|
||||
details: escapeHtml(skipReason),
|
||||
labels: "None",
|
||||
status: "Skipped",
|
||||
};
|
||||
}
|
||||
|
||||
if (summary.blocked) {
|
||||
return {
|
||||
...summary,
|
||||
comment: escapeTableText(summary.comment),
|
||||
details: escapeHtml(
|
||||
analysis.blockers.map((blocker) => blocker.message).join("; "),
|
||||
),
|
||||
labels: escapeHtml(summary.labels),
|
||||
status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...summary,
|
||||
comment: escapeTableText(summary.comment),
|
||||
labels: escapeHtml(summary.labels),
|
||||
};
|
||||
}
|
||||
|
||||
async function isOfficialMaintainer({ github, owner, repo, pr }) {
|
||||
if (MAINTAINER_LOGINS.has(pr.user.login)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner,
|
||||
repo,
|
||||
username: pr.user.login,
|
||||
});
|
||||
|
||||
return MAINTAINER_PERMISSIONS.has(response.data.permission);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureManagedLabels({ github, owner, repo }) {
|
||||
for (const label of Object.values(LABELS)) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label.name,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
description: label.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) {
|
||||
const desired = new Set(desiredLabels);
|
||||
|
||||
await ensureManagedLabels({ github, owner, repo });
|
||||
|
||||
for (const labelName of MANAGED_LABEL_NAMES) {
|
||||
if (desired.has(labelName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
name: labelName,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (desired.size > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
labels: [...desired],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function findPolicyComment({ github, owner, repo, issueNumber }) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
return comments.find(
|
||||
(comment) =>
|
||||
comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
|
||||
);
|
||||
}
|
||||
|
||||
async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) {
|
||||
const existingComment = await findPolicyComment({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
});
|
||||
|
||||
if (existingComment == null) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existingComment.id,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePolicyComment({ github, owner, repo, issueNumber }) {
|
||||
const existingComment = await findPolicyComment({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
});
|
||||
|
||||
if (existingComment == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existingComment.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function requestMaintainerReview({ github, owner, repo, pr }) {
|
||||
if (pr.user.login === REVIEWER_LOGIN) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.pulls.requestReviewers({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
reviewers: [REVIEWER_LOGIN],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status === 422) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPullRequest({
|
||||
github,
|
||||
core,
|
||||
owner,
|
||||
repo,
|
||||
pullNumber,
|
||||
dryRun,
|
||||
minimumAutomaticPullNumber,
|
||||
}) {
|
||||
const response = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
const pr = response.data;
|
||||
const issueNumber = pr.number;
|
||||
|
||||
if (pr.user.type === "Bot") {
|
||||
core.notice(
|
||||
`Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`,
|
||||
);
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: `bot @${pr.user.login}`,
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
minimumAutomaticPullNumber != null &&
|
||||
pr.number < minimumAutomaticPullNumber
|
||||
) {
|
||||
core.notice(
|
||||
`Skipping contribution policy for PR #${pr.number} because automatic checks start at PR #${minimumAutomaticPullNumber}.`,
|
||||
);
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: `before automatic rollout PR #${minimumAutomaticPullNumber}`,
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (pr.draft) {
|
||||
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: "draft",
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (await isOfficialMaintainer({ github, owner, repo, pr })) {
|
||||
core.notice(
|
||||
`Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
|
||||
);
|
||||
if (!dryRun) {
|
||||
await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
|
||||
await deletePolicyComment({ github, owner, repo, issueNumber });
|
||||
}
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: `maintainer @${pr.user.login}`,
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
const analysis = analyzePullRequest(pr);
|
||||
|
||||
if (dryRun) {
|
||||
const summary = summarizeResult({ pr, analysis });
|
||||
core.notice(
|
||||
`[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
|
||||
);
|
||||
return {
|
||||
blocked: analysis.blockers.length > 0,
|
||||
number: pr.number,
|
||||
summary,
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
await syncLabels({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
desiredLabels: analysis.desiredLabels,
|
||||
});
|
||||
|
||||
if (analysis.blockers.length > 0) {
|
||||
await upsertPolicyComment({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
body: buildPolicyComment(analysis),
|
||||
});
|
||||
return {
|
||||
blocked: true,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({ pr, analysis }),
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
await upsertPolicyComment({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
body: buildPolicyComment(analysis),
|
||||
});
|
||||
await requestMaintainerReview({ github, owner, repo, pr });
|
||||
core.notice(`Contribution policy check passed for PR #${pr.number}.`);
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({ pr, analysis }),
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function listOpenPullRequests({ github, owner, repo }) {
|
||||
return github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
});
|
||||
}
|
||||
|
||||
function getManualPullRequestNumbers({ context, core }) {
|
||||
const value = String(context.payload.inputs?.pr || "all").trim();
|
||||
|
||||
if (value.toLowerCase() === "all") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pullNumber = Number(value);
|
||||
|
||||
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
|
||||
core.setFailed('The "pr" input must be "all" or a positive PR number.');
|
||||
return [];
|
||||
}
|
||||
|
||||
return [pullNumber];
|
||||
}
|
||||
|
||||
async function run({ github, context, core }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const payloadPr = context.payload.pull_request;
|
||||
const dryRunInput = context.payload.inputs?.dry_run;
|
||||
const dryRun =
|
||||
context.eventName === "workflow_dispatch" &&
|
||||
dryRunInput !== false &&
|
||||
dryRunInput !== "false";
|
||||
const minimumAutomaticPullNumber =
|
||||
payloadPr == null ? null : MIN_AUTOMATIC_PR_NUMBER;
|
||||
let pullNumbers;
|
||||
|
||||
if (payloadPr != null) {
|
||||
pullNumbers = [payloadPr.number];
|
||||
} else {
|
||||
pullNumbers = getManualPullRequestNumbers({ context, core });
|
||||
}
|
||||
|
||||
if (pullNumbers?.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pullRequests =
|
||||
pullNumbers == null
|
||||
? await listOpenPullRequests({ github, owner, repo })
|
||||
: pullNumbers.map((number) => ({ number }));
|
||||
const results = [];
|
||||
|
||||
if (dryRun) {
|
||||
core.notice(
|
||||
`Running contribution policy in dry-run mode for ${
|
||||
pullNumbers == null
|
||||
? "all open PRs"
|
||||
: pullNumbers.map((number) => `#${number}`).join(", ")
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const pr of pullRequests) {
|
||||
results.push(
|
||||
await checkPullRequest({
|
||||
github,
|
||||
core,
|
||||
owner,
|
||||
repo,
|
||||
pullNumber: pr.number,
|
||||
dryRun,
|
||||
minimumAutomaticPullNumber,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await core.summary
|
||||
.addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
|
||||
.addTable([
|
||||
[
|
||||
{ data: "PR", header: true },
|
||||
{ data: "Title", header: true },
|
||||
{ data: "Status", header: true },
|
||||
{ data: "Labels", header: true },
|
||||
{ data: "Details", header: true },
|
||||
{ data: "Comment", header: true },
|
||||
],
|
||||
...results.map((result) => [
|
||||
result.summary.prLink,
|
||||
result.summary.title,
|
||||
result.summary.status,
|
||||
result.summary.labels,
|
||||
result.summary.details,
|
||||
result.summary.comment,
|
||||
]),
|
||||
])
|
||||
.write();
|
||||
|
||||
const blockedPullRequests = results.filter((result) => result.blocked);
|
||||
|
||||
if (blockedPullRequests.length > 0) {
|
||||
if (dryRun) {
|
||||
core.warning(
|
||||
`Dry run found contribution policy failures for PR(s): ${blockedPullRequests
|
||||
.map((result) => `#${result.number}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
core.setFailed(
|
||||
`Contribution policy failed for PR(s): ${blockedPullRequests
|
||||
.map((result) => `#${result.number}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
analyzePullRequest,
|
||||
run,
|
||||
};
|
||||
79
.github/scripts/preserve-release-artifacts.mjs
vendored
Normal file
79
.github/scripts/preserve-release-artifacts.mjs
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
// Retain only distributable bundle files, never the target tree or signing material.
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
createReadStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const env = process.env;
|
||||
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
|
||||
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
|
||||
const extensions =
|
||||
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
|
||||
const files = [];
|
||||
|
||||
function snapshot(directory) {
|
||||
if (!existsSync(directory)) return;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const source = join(directory, entry.name);
|
||||
// Only descend through the bundler's immediate output folders. This excludes
|
||||
// .app trees and the extracted CEF deb contents (which contain executables).
|
||||
if (entry.isDirectory() && directory === bundle) {
|
||||
snapshot(source);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
|
||||
) {
|
||||
// Match tauri-action's default macOS updater asset naming exactly.
|
||||
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
|
||||
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
|
||||
mkdirSync(destination, { recursive: true });
|
||||
const retained = join(destination, assetName);
|
||||
// The second snapshot must not overwrite the original user installer/signature.
|
||||
if (!existsSync(retained)) copyFileSync(source, retained);
|
||||
files.push({ source: relative(".", source), assetName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(bundle);
|
||||
if (existsSync(destination)) {
|
||||
const assets = readdirSync(destination)
|
||||
.filter((name) => name !== "provenance.json")
|
||||
.sort();
|
||||
const checksums = [];
|
||||
for (const assetName of assets) {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
|
||||
checksums.push({ assetName, sha256: hash.digest("hex") });
|
||||
}
|
||||
writeFileSync(
|
||||
join(destination, "provenance.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
repository: env.GITHUB_REPOSITORY,
|
||||
tag: env.GITHUB_REF_NAME,
|
||||
sha: env.GITHUB_SHA,
|
||||
runId: env.GITHUB_RUN_ID,
|
||||
runAttempt: env.GITHUB_RUN_ATTEMPT,
|
||||
workflowRef: env.GITHUB_WORKFLOW_REF,
|
||||
workflowSha: env.GITHUB_WORKFLOW_SHA,
|
||||
tauriAction: "tauri-apps/tauri-action@v0",
|
||||
platform: env.RELEASE_PLATFORM,
|
||||
runtime: env.RELEASE_RUNTIME,
|
||||
arch: env.RELEASE_ARCH,
|
||||
target: env.RELEASE_TARGET,
|
||||
args: env.RELEASE_ARGS,
|
||||
assets: checksums,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
}
|
||||
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
|
||||
Reference in New Issue
Block a user