Skip to content
Merged
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
98 changes: 98 additions & 0 deletions .github/scripts/bug-server-dispatch.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
async function resolveBugServerTarget({ github, context, prNumber, headSha }) {
const defaultBranch = context.payload.repository.default_branch;
if (context.ref !== `refs/heads/${defaultBranch}`) {
throw new Error(`Run this workflow from the default branch (${defaultBranch}).`);
}
if (!/^[1-9][0-9]*$/.test(prNumber) || !Number.isSafeInteger(Number(prNumber))) {
throw new Error('PR number must be a positive integer.');
}
if (headSha.length !== 40 || !/^[0-9a-f]+$/i.test(headSha)) {
throw new Error('Head SHA must be a full 40-character hexadecimal commit SHA.');
}

const { data: pull } = await github.rest.pulls.get({
...context.repo,
pull_number: Number(prNumber)
});
const repository = `${context.repo.owner}/${context.repo.repo}`;
if (pull.base.repo.full_name !== repository) {
throw new Error(`PR base repository must be ${repository}.`);
}
const sha = headSha.toLowerCase();
if (pull.head.sha !== sha) {
throw new Error(
`PR #${prNumber} head changed: expected ${sha}, current ${pull.head.sha}. Review the current head before retrying.`
);
}

if (!pull.head.repo) {
throw new Error('The PR head repository no longer exists.');
}
const workflowPath = '.github/workflows/bug-server-pr-bundle.yml';
const { data: workflow } = await github.rest.actions.getWorkflow({
...context.repo,
workflow_id: 'bug-server-pr-bundle.yml'
});
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
...context.repo,
workflow_id: workflow.id,
event: 'pull_request',
head_sha: sha,
status: 'success',
per_page: 100
});
const run = runs
.filter(
candidate =>
candidate.workflow_id === workflow.id &&
candidate.path === workflowPath &&
candidate.event === 'pull_request' &&
candidate.status === 'completed' &&
candidate.conclusion === 'success' &&
candidate.head_sha === sha &&
candidate.head_branch === pull.head.ref &&
candidate.repository?.id === pull.base.repo.id &&
candidate.head_repository?.id === pull.head.repo.id &&
// GitHub omits PR associations for fork runs. Repository, branch and SHA still bind the source.
(!candidate.pull_requests?.length || candidate.pull_requests.some(pr => pr.number === Number(prNumber)))
)
.sort((a, b) => b.id - a.id)[0];
if (!run) {
throw new Error(
`No successful PR bundle build for PR #${prNumber} at ${sha}. Wait for or re-run Bug Server PR Bundle.`
);
}
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
...context.repo,
run_id: run.id,
per_page: 100
});
const matches = artifacts.filter(artifact => artifact.name === `bug-server-pr-${prNumber}-${sha}`);
if (matches.length !== 1) {
throw new Error(`Expected exactly one PR bundle artifact in run ${run.id}. Re-run Bug Server PR Bundle.`);
}
const artifact = matches[0];
if (artifact.expired) {
throw new Error('The PR bundle artifact has expired. Re-run Bug Server PR Bundle.');
}
if (
artifact.workflow_run?.id !== run.id ||
artifact.workflow_run.head_sha !== sha ||
artifact.workflow_run.repository_id !== pull.base.repo.id ||
artifact.workflow_run.head_repository_id !== pull.head.repo.id
) {
throw new Error('Artifact provenance does not match the reviewed PR build.');
}

return {
prNumber: Number(prNumber),
sha,
headRef: pull.head.ref,
prUrl: pull.html_url,
runId: run.id,
runUrl: run.html_url,
artifactId: artifact.id
};
}

module.exports = { resolveBugServerTarget };
285 changes: 285 additions & 0 deletions .github/scripts/bug-server-dispatch.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const { resolveBugServerTarget } = require('./bug-server-dispatch.cjs');

const sha = '40be3619d1608aa1d5827f0a465704eeb036a7d3';

function fixture(overrides = {}) {
const calls = [];
const context = {
repo: { owner: 'VisActor', repo: 'VRender' },
ref: 'refs/heads/develop',
payload: { repository: { default_branch: 'develop' } }
};
const pull = {
base: { repo: { id: 1, full_name: 'VisActor/VRender' } },
head: { sha, ref: 'feat/line-render-contribution', repo: { id: 2, full_name: 'g1f9/VRender' } },
html_url: 'https://github.com/VisActor/VRender/pull/2128'
};
const run = {
id: 100,
workflow_id: 50,
path: '.github/workflows/bug-server-pr-bundle.yml',
event: 'pull_request',
status: 'completed',
conclusion: 'success',
head_sha: sha,
head_branch: pull.head.ref,
repository: { id: 1 },
head_repository: { id: 2 },
pull_requests: [],
html_url: 'https://github.com/VisActor/VRender/actions/runs/100'
};
const artifact = {
id: 200,
name: `bug-server-pr-2128-${sha}`,
expired: false,
workflow_run: { id: 100, repository_id: 1, head_repository_id: 2, head_sha: sha }
};
const runs = [run];
const artifacts = [artifact];
const github = {
paginate: async (method, params) => {
const { data } = await method(params);
return data.workflow_runs ?? data.artifacts;
},
rest: {
actions: {
getWorkflow: async params => {
assert.equal(params.workflow_id, 'bug-server-pr-bundle.yml');
return { data: { id: 50, path: '.github/workflows/bug-server-pr-bundle.yml' } };
},
listWorkflowRuns: async params => {
assert.equal(params.workflow_id, 50);
assert.equal(params.head_sha, sha);
assert.equal(params.event, 'pull_request');
assert.equal(params.status, 'success');
return { data: { workflow_runs: runs } };
},
listWorkflowRunArtifacts: async params => {
assert.equal(params.run_id, 100);
return { data: { artifacts } };
}
},
pulls: {
get: async params => {
calls.push(params);
return { data: pull };
}
}
}
};
return {
args: { github, context, prNumber: '2128', headSha: sha, ...overrides },
calls,
pull,
run,
artifact,
runs,
artifacts
};
}

test('resolves the reviewed fork head, including source metadata', async () => {
const { args, calls } = fixture({ headSha: sha.toUpperCase() });
assert.deepEqual(await resolveBugServerTarget(args), {
prNumber: 2128,
sha,
headRef: 'feat/line-render-contribution',
prUrl: 'https://github.com/VisActor/VRender/pull/2128',
runId: 100,
runUrl: 'https://github.com/VisActor/VRender/actions/runs/100',
artifactId: 200
});
assert.deepEqual(calls, [{ owner: 'VisActor', repo: 'VRender', pull_number: 2128 }]);
});

for (const prNumber of ['', '0', '-1', '1.5', '2128;echo injected', '9007199254740992']) {
test(`rejects invalid PR number ${JSON.stringify(prNumber)} before API access`, async () => {
const { args, calls } = fixture({ prNumber });
await assert.rejects(resolveBugServerTarget(args), /PR number/);
assert.equal(calls.length, 0);
});
}

for (const headSha of ['', '40be3619', 'g'.repeat(40), `${sha}\n`]) {
test(`rejects invalid SHA ${JSON.stringify(headSha)} before API access`, async () => {
const { args, calls } = fixture({ headSha });
await assert.rejects(resolveBugServerTarget(args), /40-character/);
assert.equal(calls.length, 0);
});
}

test('rejects stale approval when the PR has a different head', async () => {
const { args, pull } = fixture();
pull.head.sha = 'a'.repeat(40);
await assert.rejects(resolveBugServerTarget(args), /head changed/);
});

test('rejects a workflow launched from a non-default branch', async () => {
const { args, calls } = fixture();
args.context.ref = 'refs/heads/feature';
await assert.rejects(resolveBugServerTarget(args), /default branch/);
assert.equal(calls.length, 0);
});

test('rejects a PR belonging to another base repository', async () => {
const { args, pull } = fixture();
pull.base.repo.full_name = 'someone/VRender';
await assert.rejects(resolveBugServerTarget(args), /base repository/);
});

test('propagates API lookup failures without producing a build target', async () => {
const { args } = fixture();
args.github.rest.pulls.get = async () => {
throw new Error('Not Found');
};
await assert.rejects(resolveBugServerTarget(args), /Not Found/);
});

for (const [name, change] of [
[
'wrong workflow',
run => {
run.workflow_id = 51;
}
],
[
'wrong workflow path',
run => {
run.path = '.github/workflows/other.yml';
}
],
[
'wrong event',
run => {
run.event = 'workflow_dispatch';
}
],
[
'wrong run SHA',
run => {
run.head_sha = 'a'.repeat(40);
}
],
[
'wrong base repository',
run => {
run.repository.id = 3;
}
],
[
'wrong head repository',
run => {
run.head_repository.id = 3;
}
],
[
'wrong source branch',
run => {
run.head_branch = 'another-branch';
}
],
[
'wrong PR association',
run => {
run.pull_requests = [{ number: 2135 }];
}
],
[
'failed build',
run => {
run.conclusion = 'failure';
}
],
[
'unfinished build',
run => {
run.status = 'in_progress';
}
]
]) {
test(`rejects artifact source with ${name}`, async () => {
const { args, run } = fixture();
change(run);
await assert.rejects(resolveBugServerTarget(args), /No successful PR bundle build/);
});
}

test('accepts an explicit matching PR association', async () => {
const { args, run } = fixture();
run.pull_requests = [{ number: 2128 }];
assert.equal((await resolveBugServerTarget(args)).artifactId, 200);
});

test('rejects missing workflow runs', async () => {
const { args, runs } = fixture();
runs.length = 0;
await assert.rejects(resolveBugServerTarget(args), /No successful PR bundle build/);
});

test('does not select an older run instead of the latest matching run', async () => {
const { args, run, runs } = fixture();
runs.unshift({ ...run, id: 99 });
assert.equal((await resolveBugServerTarget(args)).runId, 100);
});

for (const [name, change] of [
[
'expired',
artifact => {
artifact.expired = true;
}
],
[
'wrong run',
artifact => {
artifact.workflow_run.id = 101;
}
],
[
'wrong head SHA',
artifact => {
artifact.workflow_run.head_sha = 'a'.repeat(40);
}
],
[
'wrong base repository',
artifact => {
artifact.workflow_run.repository_id = 3;
}
],
[
'wrong head repository',
artifact => {
artifact.workflow_run.head_repository_id = 3;
}
]
]) {
test(`rejects ${name} artifact`, async () => {
const { args, artifact } = fixture();
change(artifact);
await assert.rejects(resolveBugServerTarget(args), /Artifact provenance|expired/);
});
}

test('rejects an artifact from a different PR or SHA', async () => {
const { args, artifact } = fixture();
artifact.name = `bug-server-pr-2135-${sha}`;
await assert.rejects(resolveBugServerTarget(args), /exactly one/);
});

test('rejects missing or ambiguous artifacts', async () => {
for (const count of [0, 2]) {
const { args, artifacts, artifact } = fixture();
artifacts.splice(0, 1, ...Array(count).fill(artifact));
await assert.rejects(resolveBugServerTarget(args), /exactly one/);
}
});

test('does not silently fall back when the newest run artifact has expired', async () => {
const { args, run, runs, artifact } = fixture();
runs.push({ ...run, id: 99 });
artifact.expired = true;
await assert.rejects(resolveBugServerTarget(args), /expired/);
});
Loading
Loading