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
123 changes: 123 additions & 0 deletions .github/scripts/retarget-execution.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
const PREFIX = 'ci/retarget-execution/';
const KIND = 'bitkit-retarget-execution-v1';
const SHA = /^[0-9a-f]{40}$/;
const ownedRef = /^refs\/heads\/ci\/retarget-execution\/pr-(\d+)-(\d+)-(\d+)$/;

async function readRef(github, repo, branch) {
try {
return (await github.rest.git.getRef({...repo, ref: `heads/${branch}`})).data;
} catch (error) {
if (error.status === 404) return null;
throw error;
}
}

function metadata(commit) {
try {
return JSON.parse(commit.commit.message.split('\n\n').slice(1).join('\n\n'));
} catch {
return null;
}
}

function verify(commit, expected, tree) {
const recorded = metadata(commit);
if (!SHA.test(commit.sha || '') || commit.parents?.length !== 1 || commit.parents[0].sha !== expected.sourceSha ||
commit.commit.tree.sha !== tree || !commit.commit.verification?.verified || commit.commit.verification.reason !== 'valid' ||
commit.author?.login !== expected.owner || JSON.stringify(recorded) !== JSON.stringify(expected)) {
throw new Error('Execution commit identity, signature, metadata, parent or tree did not verify');
}
}

async function prepareExecutionRef({github, context, number, generation, sourceRef}) {
if (!/^\d+-\d+$/.test(generation) || !/^\d+$/.test(String(number)) || !sourceRef || sourceRef.replace(/^refs\/heads\//, '').startsWith(PREFIX)) throw new Error('Invalid execution source or generation');
const {viewer} = await github.graphql('query { viewer { login } }');
const branch = `${PREFIX}pr-${number}-${generation}`;
let ref = await readRef(github, context.repo, branch);
if (ref) {
const {data: existing} = await github.rest.repos.getCommit({...context.repo, ref: ref.object.sha});
const recorded = metadata(existing);
if (recorded?.kind === KIND && recorded.repository === `${context.repo.owner}/${context.repo.repo}` && recorded.pr === Number(number) &&
recorded.generation === generation && recorded.sourceRef === sourceRef && recorded.owner === viewer.login && SHA.test(recorded.sourceSha || '')) {
const {data: parent} = await github.rest.repos.getCommit({...context.repo, ref: recorded.sourceSha});
verify(existing, recorded, parent.commit.tree.sha);
return {ref: branch, sha: existing.sha, sourceSha: recorded.sourceSha};
}
}
const {data: source} = await github.rest.repos.getCommit({...context.repo, ref: sourceRef});
if (!viewer?.login || !SHA.test(source.sha || '') || !SHA.test(source.commit?.tree?.sha || '')) throw new Error('Could not resolve the workflow source identity');
const expected = {kind: KIND, repository: `${context.repo.owner}/${context.repo.repo}`, pr: Number(number), generation, sourceSha: source.sha, sourceRef, owner: viewer.login};
const created = !ref;
if (!ref) {
try {
({data: ref} = await github.rest.git.createRef({...context.repo, ref: `refs/heads/${branch}`, sha: source.sha}));
} catch (error) {
ref = await readRef(github, context.repo, branch);
if (!ref) throw error;
}
}
if (ref.object.sha === source.sha) {
try {
// This API signs as the authenticated GitHub identity; never use unsigned Git database creation.
await github.graphql(`mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) { commit { oid } }
}`, {input: {branch: {repositoryNameWithOwner: expected.repository, branchName: branch}, expectedHeadOid: source.sha,
fileChanges: {additions: [], deletions: []},
message: {headline: `ci: isolated retarget validation for PR #${number} [skip ci]`, body: JSON.stringify(expected)}}});
} catch (error) {
// A timeout may have committed successfully. Reconcile instead of appending again.
ref = await readRef(github, context.repo, branch);
if (!ref || ref.object.sha === source.sha) {
if (created && ref) {
try {await github.rest.git.deleteRef({...context.repo, ref: `heads/${branch}`});} catch { /* Retain an uncertain ref for inspection. */ }
}
throw error;
}
}
}
ref = await readRef(github, context.repo, branch);
if (!ref) throw new Error('Execution ref disappeared before dispatch');
const {data: execution} = await github.rest.repos.getCommit({...context.repo, ref: ref.object.sha});
verify(execution, expected, source.commit.tree.sha);
return {ref: branch, sha: execution.sha, sourceSha: source.sha};
}

async function cleanupExecutionRefs({github, context, core, now = Date.now()}) {
const {viewer} = await github.graphql('query { viewer { login } }');
const refs = await github.paginate(github.rest.git.listMatchingRefs, {...context.repo, ref: `heads/${PREFIX}`});
let deleted = 0;
for (const ref of refs) {
if (deleted === 100) break;
const match = ownedRef.exec(ref.ref);
if (!match || ref.object.type !== 'commit') continue;
try {
const {data: commit} = await github.rest.repos.getCommit({...context.repo, ref: ref.object.sha});
const created = Date.parse(commit.commit.committer.date);
// Actions supports reruns for 30 days. Keep an additional five-day margin.
if (!Number.isFinite(created) || now - created < 35 * 86400000) continue;
const recorded = metadata(commit);
if (recorded?.kind !== KIND || recorded.repository !== `${context.repo.owner}/${context.repo.repo}` ||
recorded.pr !== Number(match[1]) || recorded.generation !== `${match[2]}-${match[3]}` || recorded.owner !== viewer.login || !SHA.test(recorded.sourceSha || '')) continue;
const {data: source} = await github.rest.repos.getCommit({...context.repo, ref: recorded.sourceSha});
verify(commit, recorded, source.commit.tree.sha);
const branch = ref.ref.slice('refs/heads/'.length);
const {data: runs} = await github.rest.actions.listWorkflowRunsForRepo({...context.repo, branch, per_page: 100});
if (runs.total_count > runs.workflow_runs.length || runs.workflow_runs.some(run => run.status !== 'completed')) continue;
try {
const {data: helper} = await github.rest.actions.getWorkflowRun({...context.repo, run_id: Number(match[2])});
if (helper.status !== 'completed') continue;
} catch (error) {
if (error.status !== 404) throw error;
}
const current = await readRef(github, context.repo, branch);
if (current?.object.sha !== commit.sha) continue;
await github.rest.git.deleteRef({...context.repo, ref: `heads/${branch}`});
deleted++;
core.info(`Removed expired execution ref ${branch}`);
} catch (error) {
core.warning(`Kept execution ref ${ref.ref}: ${error.message}`);
}
}
}

module.exports = {prepareExecutionRef, cleanupExecutionRefs};
130 changes: 130 additions & 0 deletions .github/scripts/retarget-execution.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {prepareExecutionRef, cleanupExecutionRefs} = require('./retarget-execution.cjs');
const {executionFixture} = require('./test-fixtures/retarget-execution.cjs');

function fixture() {
const f = executionFixture();
const warnings = [], messages = [];
return {...f, context: {repo: {owner: 'owner', repo: 'repo'}}, number: 7, generation: '200-1', sourceRef: 'master',
core: {warning: value => warnings.push(value), info: value => messages.push(value)}, warnings, messages, now: Date.parse('2026-12-05T00:00:00Z')};
}

test('execution uses a distinct verified commit with the trusted workflow tree and parent', async () => {
const f = fixture(); const result = await prepareExecutionRef(f);
assert.equal(result.ref, 'ci/retarget-execution/pr-7-200-1');
assert.notEqual(result.sha, f.SOURCE);
const commit = f.commits.get(result.sha);
assert.deepEqual(commit.parents, [{sha: f.SOURCE}]);
assert.equal(commit.commit.tree.sha, f.TREE);
assert.equal(commit.commit.verification.verified, true);
assert.equal(commit.author.login, 'github-actions[bot]');
assert.deepEqual(f.mutations[0].fileChanges, {additions: [], deletions: []});
});

test('an existing generation is reconciled without appending another execution commit', async () => {
const f = fixture(); const first = await prepareExecutionRef(f);
assert.deepEqual(await prepareExecutionRef(f), first);
assert.equal(f.mutations.length, 1);
});

test('reconciliation retains its original snapshot after the source branch moves', async () => {
const f = fixture(); const first = await prepareExecutionRef(f);
const newer = 'd'.repeat(40);
f.commits.set(newer, {sha: newer, commit: {tree: {sha: 'c'.repeat(40)}}});
f.aliases.set('master', newer);
assert.deepEqual(await prepareExecutionRef(f), first);
assert.equal(f.mutations.length, 1);
});

test('a lost mutation response reconciles its signed commit instead of duplicating it', async () => {
const f = fixture(); const request = f.github.graphql;
f.github.graphql = async (query, variables) => {
const result = await request(query, variables);
if (query.includes('createCommitOnBranch')) throw new Error('Response lost');
return result;
};
const result = await prepareExecutionRef(f);
assert.ok(f.commits.has(result.sha)); assert.equal(f.mutations.length, 1);
});

test('a rejected mutation removes only its untouched preparation ref and never falls back unsigned', async () => {
const f = fixture(); const request = f.github.graphql;
f.github.graphql = (query, variables) => {
if (query.includes('createCommitOnBranch')) throw new Error('Not supported');
return request(query, variables);
};
await assert.rejects(prepareExecutionRef(f), /Not supported/);
assert.equal(f.refs.size, 0); assert.equal(f.mutations.length, 0);
});

for (const [name, mutate] of [
['signature', c => {c.commit.verification.verified = false;}],
['author', c => {c.author.login = 'other';}],
['tree', c => {c.commit.tree.sha = 'd'.repeat(40);}],
['parent', c => {c.parents[0].sha = 'd'.repeat(40);}],
['metadata', c => {c.commit.message = 'unrelated commit';}],
]) test(`a mismatched ${name} cannot be used as an execution snapshot`, async () => {
const f = fixture(); const result = await prepareExecutionRef(f);
mutate(f.commits.get(result.sha));
await assert.rejects(prepareExecutionRef(f), /did not verify/);
assert.equal(f.mutations.length, 1);
});

test('execution source cannot be another disposable execution ref', async () => {
for (const sourceRef of ['ci/retarget-execution/pr-7-200-1', 'refs/heads/ci/retarget-execution/pr-7-200-1']) {
await assert.rejects(prepareExecutionRef({...fixture(), sourceRef}), /Invalid execution source/);
}
});

test('cleanup expires only verified, owned snapshots after the rerun window', async () => {
const f = fixture(); const result = await prepareExecutionRef(f);
await cleanupExecutionRefs({...f, now: Date.parse('2026-11-28T00:00:00Z')});
assert.equal(f.deleted.length, 0);
await cleanupExecutionRefs(f);
assert.deepEqual(f.deleted, [`heads/${result.ref}`]);
});

for (const status of ['queued', 'in_progress', 'waiting', 'pending']) test(`cleanup retains an expired ref with ${status} execution`, async () => {
const f = fixture(); await prepareExecutionRef(f);
f.github.rest.actions.listWorkflowRunsForRepo = async () => ({data: {total_count: 1, workflow_runs: [{status}]}});
await cleanupExecutionRefs(f); assert.equal(f.deleted.length, 0);
});

test('cleanup retains refs whose helper is active or whose run listing is incomplete', async () => {
const f = fixture(); await prepareExecutionRef(f);
f.github.rest.actions.getWorkflowRun = async () => ({data: {status: 'in_progress'}});
await cleanupExecutionRefs(f); assert.equal(f.deleted.length, 0);
f.github.rest.actions.listWorkflowRunsForRepo = async () => ({data: {total_count: 101, workflow_runs: []}});
await cleanupExecutionRefs(f); assert.equal(f.deleted.length, 0);
});

test('cleanup refuses changed refs, foreign metadata and unsigned commits', async () => {
const f = fixture(); const result = await prepareExecutionRef(f);
const commit = f.commits.get(result.sha);
commit.commit.verification.verified = false;
await cleanupExecutionRefs(f); assert.equal(f.deleted.length, 0);
commit.commit.verification.verified = true;
const original = commit.commit.message; commit.commit.message = 'foreign';
await cleanupExecutionRefs(f); assert.equal(f.deleted.length, 0);
commit.commit.message = original;
const get = f.github.rest.git.getRef;
f.github.rest.git.getRef = async params => ({data: {...(await get(params)).data, object: {sha: f.SOURCE}}});
await cleanupExecutionRefs(f); assert.equal(f.deleted.length, 0);
});

test('retained preparation refs cannot starve a later expired execution', async () => {
const f = fixture();
for (let index = 0; index < 100; index++) f.refs.set(`heads/ci/retarget-execution/pr-7-${index + 1000}-1`, f.SOURCE);
const result = await prepareExecutionRef(f);
await cleanupExecutionRefs(f);
assert.deepEqual(f.deleted, [`heads/${result.ref}`]);
assert.equal(f.refs.size, 100);
});

test('cleanup bounds deletions and makes progress on the next run', async () => {
const f = fixture();
for (let index = 0; index < 101; index++) await prepareExecutionRef({...f, generation: `${index + 200}-1`});
await cleanupExecutionRefs(f); assert.equal(f.deleted.length, 100); assert.equal(f.refs.size, 1);
await cleanupExecutionRefs(f); assert.equal(f.deleted.length, 101); assert.equal(f.refs.size, 0);
});
25 changes: 20 additions & 5 deletions .github/scripts/retarget-tests.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const CHECKS = [
];
const runUrl = context => `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const externalId = (generation, name) => `retarget:${generation}:${name}`;
const {prepareExecutionRef} = require('./retarget-execution.cjs');

function e2eRetargetResult(jobs) {
if (jobs['detect-changes'].result !== 'success' || jobs['e2e-status'].result !== 'success') return 'failure';
Expand Down Expand Up @@ -64,11 +65,17 @@ async function dispatchRetargetTests({github, context, core, attempt = process.e
await sleep(2000);
}
if (!merge) throw new Error('No verified PR merge commit is available');
const helperRef = manual ? context.ref?.replace(/^refs\/(heads|tags)\//, '') || 'master' : 'master';
const requestedSource = manual && event.inputs.workflow_ref;
let sourceRef = 'master';
// Keep a manual generation on the helper's queued revision if its branch advances.
if (manual) sourceRef = requestedSource && requestedSource !== helperRef ? requestedSource : context.sha;
const execution = await prepareExecutionRef({github, context, number: original.number, generation, sourceRef});
for (const check of selected) {
try {
await github.rest.actions.createWorkflowDispatch({
...context.repo, workflow_id: check.workflow, ref: manual && event.inputs.workflow_ref || 'master',
inputs: {pr: String(original.number), merge_commit: merge, head_sha: sha, retarget_run: generation, check_run_id: String(checks.get(check.context)), ...(check.workflow === 'e2e-tests.yml' ? {app_branch: current.head.ref} : {})},
...context.repo, workflow_id: check.workflow, ref: execution.ref,
inputs: {pr: String(original.number), merge_commit: merge, head_sha: sha, retarget_run: generation, check_run_id: String(checks.get(check.context)), workflow_ref: execution.sourceSha, helper_ref: helperRef, ...(check.workflow === 'e2e-tests.yml' ? {app_branch: current.head.ref} : {})},
});
} catch (error) {
await github.rest.checks.update({
Expand Down Expand Up @@ -107,10 +114,18 @@ async function reportRetargetResult({github, context, core, name, phase, result,
if (check.status === 'completed') {
if (phase === 'start') {
// Completed checks cannot reopen; the helper owns creation of fresh checks.
const workflowRef = context.ref?.replace(/^refs\/heads\//, '') || 'master';
let helperRef = inputs.RETARGET_HELPER_REF || 'master';
if (helperRef.replace(/^refs\/heads\//, '').startsWith('ci/retarget-execution/')) throw new Error('Recovery helper cannot use an execution ref');
try {
await github.rest.repos.getCommit({...context.repo, ref: helperRef});
} catch (error) {
// The commits endpoint returns 422 when a previously valid ref no longer exists.
if (![404, 422].includes(error.status)) throw error;
helperRef = 'master';
}
await github.rest.actions.createWorkflowDispatch({
...context.repo, workflow_id: 'e2e-retarget.yml', ref: workflowRef,
inputs: {pr: number, workflow_ref: workflowRef, suite: CHECKS.find(check => check.context === name).suite},
...context.repo, workflow_id: 'e2e-retarget.yml', ref: helperRef,
inputs: {pr: number, suite: CHECKS.find(check => check.context === name).suite},
});
core.setFailed('Started a fresh retarget validation; completed checks cannot be reopened.');
}
Expand Down
Loading
Loading