blob: eee3f3a6b2e38d9a4db9e5aca00d80902316ac37 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Posts (or updates) the E2E summary comments for the pull request a completed E2E run
* belongs to — including pull requests from forks.
*
* ## Why this is a separate workflow at all
*
* A `pull_request` run triggered from a fork gets a read-only `GITHUB_TOKEN`. That is
* GitHub's design: the run executes the fork's code, so it must not hold a token that can
* write to this repository. The consequence is that the E2E jobs cannot comment on exactly
* the pull requests that most need the feedback — an outside contributor previously had to
* open the run summary to see whether their change passed.
*
* `workflow_run` is the supported way out. It fires *after* the untrusted run finishes, and
* the workflow that runs is the one on the **default branch**, with a token this repository
* controls. The untrusted half produces a file; this half publishes it.
*
* ## What makes that safe, and what it does not
*
* The dangerous version of this pattern — "pwn request" — checks out or executes the pull
* request's code while holding the writable token. This does neither. The calling workflow
* checks out the base repository's default branch and runs only this file; the artifact is
* read as data and never evaluated, interpolated into a shell, or passed through `${{ }}`.
*
* Two further rules, both load-bearing:
*
* - **The pull request is resolved from the event, never from the artifact.** The artifact
* is written by a run of the contributor's own branch, so anything inside it is
* attacker-controlled — including, if it were trusted, which issue to comment on. The
* target is derived from `workflow_run.head_sha` and cross-checked against the head
* repository the event reports.
* - **The marker and title come from the allow-list below, not from the artifact.** The
* marker decides which existing comment gets overwritten. Taking it from the artifact
* would let a fork overwrite an arbitrary comment on its own pull request.
*
* The residual risk is inherent to commenting on fork pull requests at all: a contributor
* can edit `scripts/e2e-summary.mjs` on their branch and make the summary say anything, and
* that text is then posted under the Actions bot's name on their own pull request. Size is
* capped and `@`-mentions are defused below so it cannot be turned into notification spam,
* but the text itself is not trustworthy and the comment says so.
*/
const fs = require('node:fs');
const path = require('node:path');
/**
* Artifacts this will publish, and the identity each one is published under.
*
* An allow-list rather than a pattern: artifact *names* are chosen by the untrusted run, so
* an unrecognised one is ignored rather than posted. Keys must match the `name:` given to
* `upload-artifact` in e2e.yml.
*/
const KNOWN_SUMMARIES = {
'pr-comment-e2e-mocked': {
marker: '<!-- e2e-report-mocked -->',
title: 'E2E — mocked backend',
artifact: 'playwright-report-mocked',
},
'pr-comment-e2e-backend': {
marker: '<!-- e2e-report-backend -->',
title: 'E2E — real Fineract',
artifact: 'playwright-report-backend',
},
'pr-comment-diagram': {
marker: '<!-- pr-sequence-diagram -->',
title: 'What this change talks to',
// No downloadable artifact: the diagram is the whole payload.
artifact: null,
},
};
/** Leaves room under GitHub's 65536-character comment limit for the wrapper below. */
const MAX_SUMMARY_CHARS = 60000;
/**
* Defuses `@mentions` and issue cross-references in untrusted text.
*
* Without this a fork could make every summary mention a maintainer, or cross-link an
* unrelated issue, and each posted comment would notify them. The characters are kept — the
* text still reads correctly — but a zero-width space stops GitHub parsing them as a
* reference.
*/
function defuseReferences(text) {
return text.replace(/@(?=[A-Za-z0-9])/g, '@​').replace(/#(?=\d)/g, '#​');
}
/** Reads whichever `.md` file the artifact contained, or null when there is none. */
function readSummary(dir) {
let entries;
try {
entries = fs.readdirSync(dir);
} catch {
return null;
}
const file = entries.find((name) => name.endsWith('.md'));
if (!file) return null;
try {
return fs.readFileSync(path.join(dir, file), 'utf8');
} catch {
return null;
}
}
/**
* Finds the pull request this run belongs to, from the event alone.
*
* `workflow_run.pull_requests` is empty for forks, which is precisely the case that matters,
* so it is not used. The head SHA is; it comes from the event payload and a contributor
* cannot forge it into pointing at someone else's branch.
*/
async function resolvePullRequest({ github, context, run }) {
const { owner, repo } = context.repo;
const byCommit = await github.rest.repos
.listPullRequestsAssociatedWithCommit({ owner, repo, commit_sha: run.head_sha })
.then((response) => response.data)
.catch(() => []);
const headRepo = run.head_repository?.full_name;
const match = byCommit.find(
(pr) => pr.head.sha === run.head_sha && (!headRepo || pr.head.repo?.full_name === headRepo),
);
if (match) return match;
// Fallback for the case where the commit-association index has not caught up: ask for open
// pull requests from this head branch, then re-check the SHA rather than trusting the name.
if (!headRepo || !run.head_branch) return null;
const [headOwner] = headRepo.split('/');
const byBranch = await github.rest.pulls
.list({ owner, repo, state: 'open', head: `${headOwner}:${run.head_branch}` })
.then((response) => response.data)
.catch(() => []);
return byBranch.find((pr) => pr.head.sha === run.head_sha) ?? null;
}
async function upsert({ github, context, issueNumber, marker, body }) {
const { owner, repo } = context.repo;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
const existing = comments.find(
(comment) => comment.user?.type === 'Bot' && comment.body?.includes(marker),
);
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
return 'updated';
}
await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body });
return 'created';
}
module.exports = async ({ github, context, core }) => {
const run = context.payload.workflow_run;
const log = core?.info ?? console.log;
const pull = await resolvePullRequest({ github, context, run });
if (!pull) {
// Not an error: a push to a branch with no open pull request reaches here routinely.
log(`No open pull request for ${run.head_sha}; nothing to comment on.`);
return;
}
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${run.id}`;
const isFork = run.head_repository?.full_name !== `${context.repo.owner}/${context.repo.repo}`;
let posted = 0;
for (const [name, config] of Object.entries(KNOWN_SUMMARIES)) {
const raw = readSummary(path.join('summaries', name));
if (raw === null) {
log(`No summary in ${name}; skipping.`);
continue;
}
let summary = defuseReferences(raw);
if (summary.length > MAX_SUMMARY_CHARS) {
summary = `${summary.slice(0, MAX_SUMMARY_CHARS)}\n\n_Summary truncated at ${MAX_SUMMARY_CHARS} characters._`;
}
// A summary that already opens with its own marker and heading — the diagram does —
// is posted as-is; doubling the heading would read as a rendering bug.
const carriesOwnHeading = raw.trimStart().startsWith(config.marker);
const body = [
config.marker,
...(carriesOwnHeading ? [] : [`### ${config.title}`]),
carriesOwnHeading ? summary.replace(config.marker, '').trimStart() : summary,
'',
...(config.artifact
? [
`📼 [Download the HTML report, videos and traces](${runUrl}) see the \`${config.artifact}\` artifact.`,
]
: []),
// Stated on every comment, not only fork ones: a reader should not have to know which
// kind of pull request they are looking at to know how much to trust the text.
'',
`<sub>Generated by [run ${run.id}](${runUrl}) from \`${run.head_sha.slice(0, 7)}\`.` +
(isFork ? ' The run executed a fork branch, so treat its contents as unverified.' : '') +
'</sub>',
].join('\n');
const action = await upsert({
github,
context,
issueNumber: pull.number,
marker: config.marker,
body,
});
log(`${action} the ${config.title} comment on #${pull.number}.`);
posted += 1;
}
if (!posted) log('No recognised summary artifacts were present.');
};