diff --git a/.github/scripts/retarget-execution.cjs b/.github/scripts/retarget-execution.cjs new file mode 100644 index 000000000..d55d2e16d --- /dev/null +++ b/.github/scripts/retarget-execution.cjs @@ -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}; diff --git a/.github/scripts/retarget-execution.test.cjs b/.github/scripts/retarget-execution.test.cjs new file mode 100644 index 000000000..f456d6c89 --- /dev/null +++ b/.github/scripts/retarget-execution.test.cjs @@ -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); +}); diff --git a/.github/scripts/retarget-tests.cjs b/.github/scripts/retarget-tests.cjs index 87c53034f..5ce72cb8f 100644 --- a/.github/scripts/retarget-tests.cjs +++ b/.github/scripts/retarget-tests.cjs @@ -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'; @@ -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({ @@ -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.'); } diff --git a/.github/scripts/retarget-tests.test.cjs b/.github/scripts/retarget-tests.test.cjs index b3e42e5a4..99930059f 100644 --- a/.github/scripts/retarget-tests.test.cjs +++ b/.github/scripts/retarget-tests.test.cjs @@ -1,6 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const {dispatchRetargetTests, reportRetargetResult, e2eRetargetResult} = require('./retarget-tests.cjs'); +const {executionFixture} = require('./test-fixtures/retarget-execution.cjs'); const HEAD = 'a'.repeat(40); const BASE = 'b'.repeat(40); @@ -10,16 +11,17 @@ const INTEGRATION = 'Run Integration Tests'; const inputs = (id = 1, generation = '200-1') => ({RETARGET_CHECK_RUN_ID: String(id), RETARGET_RUN: generation, RETARGET_HEAD_SHA: HEAD, RETARGET_MERGE_COMMIT: MERGE, RETARGET_PR: '7'}); function fixture() { + const execution = executionFixture(); const pr = {number: 7, state: 'open', draft: false, head: {sha: HEAD, ref: 'feature', repo: {full_name: 'owner/repo'}}, base: {sha: BASE, ref: 'master'}, mergeable: true, merge_commit_sha: MERGE}; - const context = {repo: {owner: 'owner', repo: 'repo'}, eventName: 'pull_request', runId: 200, serverUrl: 'https://github.com', payload: {changes: {base: {}}, pull_request: structuredClone(pr)}}; + const context = {repo: {owner: 'owner', repo: 'repo'}, sha: execution.SOURCE, eventName: 'pull_request', runId: 200, serverUrl: 'https://github.com', payload: {changes: {base: {}}, pull_request: structuredClone(pr)}}; const checks = new Map(); const dispatches = []; const failures = []; const commits = {parents: [{sha: BASE}, {sha: HEAD}]}; const rest = { pulls: {get: async () => ({data: pr})}, - git: {getCommit: async () => ({data: commits})}, - repos: {compareCommits: async ({base, head}) => { + git: {...execution.github.rest.git, getCommit: async () => ({data: commits})}, + repos: {...execution.github.rest.repos, compareCommits: async ({base, head}) => { assert.equal(head, 'master'); return {data: {status: base === BASE ? 'identical' : 'diverged'}}; }}, @@ -34,9 +36,9 @@ function fixture() { }, actions: {createWorkflowDispatch: async value => {dispatches.push(value);}}, }; - const github = {rest}; + const github = {rest, graphql: execution.github.graphql}; const core = {setFailed: value => failures.push(value), info: () => {}}; - return {pr, context, checks, dispatches, failures, commits, github, core, attempt: '1', sleep: async () => {}}; + return {pr, context, checks, dispatches, failures, commits, github, core, execution, attempt: '1', sleep: async () => {}}; } const start = async f => {await dispatchRetargetTests(f); return f;}; @@ -48,16 +50,17 @@ test('retarget creates canonical queued checks and dispatches a pinned merge wit const f = await start(fixture()); assert.deepEqual([...f.checks.values()].map(c => [c.name, c.status]), [[E2E, 'queued'], [INTEGRATION, 'queued']]); assert.deepEqual(f.dispatches.map(d => d.workflow_id), ['e2e-tests.yml', 'integration-tests.yml']); - for (const [index, dispatch] of f.dispatches.entries()) assert.deepEqual(dispatch.inputs, {pr: '7', merge_commit: MERGE, head_sha: HEAD, retarget_run: '200-1', check_run_id: String(index + 1), ...(index === 0 ? {app_branch: 'feature'} : {})}); - assert.deepEqual(f.dispatches.map(d => d.ref), ['master', 'master']); + for (const [index, dispatch] of f.dispatches.entries()) assert.deepEqual(dispatch.inputs, {pr: '7', merge_commit: MERGE, head_sha: HEAD, retarget_run: '200-1', check_run_id: String(index + 1), workflow_ref: f.execution.SOURCE, helper_ref: 'master', ...(index === 0 ? {app_branch: 'feature'} : {})}); + assert.deepEqual(f.dispatches.map(d => d.ref), ['ci/retarget-execution/pr-7-200-1', 'ci/retarget-execution/pr-7-200-1']); assert.deepEqual(f.failures, []); }); test('manual recovery can validate a PR with workflow definitions from a specified branch', async () => { const f = fixture(); f.context.eventName = 'workflow_dispatch'; + f.context.ref = 'refs/heads/ci/fix'; f.context.payload = {inputs: {pr: '7', workflow_ref: 'ci/fix'}}; await start(f); - assert.deepEqual(f.dispatches.map(d => d.ref), ['ci/fix', 'ci/fix']); + assert.ok(f.dispatches.every(d => d.ref === 'ci/retarget-execution/pr-7-200-1' && d.inputs.workflow_ref === f.execution.SOURCE && d.inputs.helper_ref === 'ci/fix')); }); test('a later helper attempt owns distinct checks that its reporter can complete', async () => { @@ -147,7 +150,7 @@ test('rerunning a completed suite requests fresh validation instead of reopening assert.equal(f.checks.get(1).conclusion, 'failure'); assert.equal(f.checks.size, 2); assert.equal(f.dispatches[2].workflow_id, 'e2e-retarget.yml'); - assert.deepEqual(f.dispatches[2].inputs, {pr: '7', workflow_ref: 'master', suite: 'e2e'}); + assert.deepEqual(f.dispatches[2].inputs, {pr: '7', suite: 'e2e'}); assert.equal(f.failures.length, 1); await report(f); assert.equal(f.checks.get(1).conclusion, 'failure'); @@ -256,3 +259,108 @@ test('only E2E can report not required, with an explicit check explanation', asy await report(f, {name: INTEGRATION, inputs: inputs(2), result: 'not_required'}); assert.equal(f.checks.get(2).conclusion, 'failure'); }); + +test('retry recovery selects the stable helper without forwarding the old pinned child source', async () => { + const f = await start(fixture()); + f.context.ref = 'refs/heads/ci/retarget-execution/pr-7-200-1'; + await report(f, {result: 'failure'}); + await report(f, {phase: 'start', inputs: {...inputs(), RETARGET_HELPER_REF: 'ci/fix'}}); + assert.equal(f.dispatches[2].ref, 'ci/fix'); + assert.deepEqual(f.dispatches[2].inputs, {pr: '7', suite: 'e2e'}); +}); + +test('unsigned execution preparation fails both PR checks and never dispatches tests on master', 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')) f.execution.commits.get(result.createCommitOnBranch.commit.oid).commit.verification.verified = false; + return result; + }; + await start(f); + assert.equal(f.dispatches.length, 0); + assert.deepEqual([...f.checks.values()].map(c => c.conclusion), ['failure', 'failure']); +}); + +for (const [suite, name, id, otherId] of [['e2e', E2E, 1, 2], ['integration', INTEGRATION, 2, 1]]) { + for (const status of [404, 422]) test(`a deleted helper (${status}) recovers only ${suite} with the current master helper`, async () => { + const f = await start(fixture()); + await report(f, {name: otherId === 1 ? E2E : INTEGRATION, inputs: inputs(otherId)}); + const other = structuredClone(f.checks.get(otherId)); + await report(f, {name, inputs: inputs(id), result: 'failure'}); + const getCommit = f.github.rest.repos.getCommit; + f.github.rest.repos.getCommit = async value => { + if (value.ref === 'deleted-branch' && status === 404) throw Object.assign(new Error('Not found'), {status}); + return getCommit(value); + }; + await report(f, {name, phase: 'start', inputs: {...inputs(id), RETARGET_HELPER_REF: 'deleted-branch'}}); + assert.equal(f.dispatches[2].ref, 'master'); + assert.deepEqual(f.dispatches[2].inputs, {pr: '7', suite}); + assert.equal(f.checks.get(id).conclusion, 'failure'); + assert.deepEqual(f.checks.get(otherId), other); + assert.equal(f.checks.size, 2); + }); +} + +for (const status of [403, 429, 500]) test(`helper lookup failure (${status}) does not silently dispatch recovery on master`, async () => { + const f = await start(fixture()); + await report(f, {result: 'failure'}); + f.github.rest.repos.getCommit = async () => {throw Object.assign(new Error('Lookup failed'), {status});}; + await assert.rejects(report(f, {phase: 'start'}), error => error.status === status); + assert.equal(f.dispatches.length, 2); + assert.equal(f.checks.get(1).conclusion, 'failure'); +}); + +for (const [suite, name, id, otherId] of [['e2e', E2E, 1, 2], ['integration', INTEGRATION, 2, 1]]) { + test(`${suite} retry uses its new helper's definitions despite an old child source and a later branch move`, async () => { + const f = await start(fixture()); + await report(f, {name: otherId === 1 ? E2E : INTEGRATION, inputs: inputs(otherId)}); + const other = structuredClone(f.checks.get(otherId)); + await report(f, {name, inputs: inputs(id), result: 'failure'}); + const helperSha = 'd'.repeat(40); + f.execution.commits.set(helperSha, {sha: helperSha, commit: {tree: {sha: '1'.repeat(40)}}}); + f.execution.aliases.set('master', helperSha); + await report(f, {name, phase: 'start', inputs: {...inputs(id), RETARGET_HELPER_REF: 'master'}}); + assert.deepEqual(f.dispatches[2].inputs, {pr: '7', suite}); + f.context.eventName = 'workflow_dispatch'; f.context.runId = 201; + f.context.ref = 'refs/heads/master'; f.context.sha = helperSha; + f.context.payload = {inputs: f.dispatches[2].inputs}; + const laterSha = '9'.repeat(40); + f.execution.commits.set(laterSha, {sha: laterSha, commit: {tree: {sha: '2'.repeat(40)}}}); + f.execution.aliases.set('master', laterSha); + await start(f); + const child = f.dispatches[3]; + const execution = f.execution.commits.get(f.execution.refs.get(`heads/${child.ref}`)); + assert.equal(execution.parents[0].sha, helperSha); + assert.equal(execution.commit.tree.sha, '1'.repeat(40)); + assert.equal(child.inputs.workflow_ref, helperSha); + assert.equal(child.inputs.head_sha, HEAD); + assert.equal(child.inputs.merge_commit, MERGE); + assert.equal(child.workflow_id, suite === 'e2e' ? 'e2e-tests.yml' : 'integration-tests.yml'); + assert.deepEqual(f.checks.get(otherId), other); + assert.equal(f.checks.size, 3); + assert.deepEqual(f.failures, ['Started a fresh retarget validation; completed checks cannot be reopened.']); + }); +} + +test('manual backfills retain an explicit compatible source override', async () => { + const f = fixture(); + f.context.eventName = 'workflow_dispatch'; f.context.ref = 'refs/heads/ci/fix'; + f.context.sha = 'd'.repeat(40); + f.context.payload = {inputs: {pr: '7', workflow_ref: f.execution.SOURCE}}; + await start(f); + assert.equal(f.execution.mutations[0].expectedHeadOid, f.execution.SOURCE); + assert.equal(f.dispatches.length, 2); + assert.deepEqual(f.failures, []); +}); + +test('a legacy helper-ref override uses that helper run revision if its branch has moved', async () => { + const f = fixture(); f.context.eventName = 'workflow_dispatch'; + f.context.ref = 'refs/heads/master'; + f.context.payload = {inputs: {pr: '7', workflow_ref: 'master'}}; + const laterSha = 'd'.repeat(40); + f.execution.commits.set(laterSha, {sha: laterSha, commit: {tree: {sha: '1'.repeat(40)}}}); + f.execution.aliases.set('master', laterSha); + await start(f); + assert.equal(f.execution.mutations[0].expectedHeadOid, f.context.sha); + assert.deepEqual(f.failures, []); +}); diff --git a/.github/scripts/test-fixtures/retarget-execution.cjs b/.github/scripts/test-fixtures/retarget-execution.cjs new file mode 100644 index 000000000..5bcfe5488 --- /dev/null +++ b/.github/scripts/test-fixtures/retarget-execution.cjs @@ -0,0 +1,50 @@ +const SOURCE = 'f'.repeat(40); +const TREE = 'e'.repeat(40); + +function executionFixture() { + const owner = 'github-actions[bot]'; + const refs = new Map(); + const commits = new Map([[SOURCE, {sha: SOURCE, commit: {tree: {sha: TREE}, message: 'Workflow source', committer: {date: '2026-10-29T00:00:00Z'}}}]]); + const aliases = new Map([['master', SOURCE], ['ci/fix', SOURCE]]); + const mutations = []; + const deleted = []; + const missing = () => Object.assign(new Error('Not found'), {status: 404}); + const git = { + getRef: async ({ref}) => { + if (!refs.has(ref)) throw missing(); + return {data: {ref: `refs/${ref}`, object: {sha: refs.get(ref), type: 'commit'}}}; + }, + createRef: async ({ref, sha}) => { + const key = ref.replace(/^refs\//, ''); + if (refs.has(key)) throw Object.assign(new Error('Exists'), {status: 422}); + refs.set(key, sha); + return git.getRef({ref: key}); + }, + listMatchingRefs: async ({ref}) => ({data: [...refs].filter(([key]) => key.startsWith(ref)).map(([key, sha]) => ({ref: `refs/${key}`, object: {sha, type: 'commit'}}))}), + deleteRef: async ({ref}) => {deleted.push(ref); refs.delete(ref);}, + }; + const repos = {getCommit: async ({ref}) => { + const sha = aliases.get(ref) || refs.get(`heads/${ref}`) || ref; + if (!commits.has(sha)) throw Object.assign(new Error(`No commit found for SHA: ${ref}`), {status: 422}); + return {data: structuredClone(commits.get(sha))}; + }}; + const graphql = async (query, variables) => { + if (!query.includes('createCommitOnBranch')) return {viewer: {login: owner}}; + const input = variables.input; + const key = `heads/${input.branch.branchName}`; + if (refs.get(key) !== input.expectedHeadOid) throw Object.assign(new Error('Head changed'), {status: 422}); + mutations.push(input); + const sha = mutations.length.toString(16).padStart(40, '0'); + commits.set(sha, {sha, author: {login: owner}, parents: [{sha: input.expectedHeadOid}], commit: { + tree: {sha: commits.get(input.expectedHeadOid).commit.tree.sha}, verification: {verified: true, reason: 'valid'}, + message: `${input.message.headline}\n\n${input.message.body}`, committer: {date: '2026-10-29T00:00:00Z'}, + }}); + refs.set(key, sha); + return {createCommitOnBranch: {commit: {oid: sha}}}; + }; + const actions = {listWorkflowRunsForRepo: async () => ({data: {total_count: 0, workflow_runs: []}}), getWorkflowRun: async () => ({data: {status: 'completed'}})}; + const github = {rest: {git, repos, actions}, graphql, paginate: async (method, params) => (await method(params)).data}; + return {github, refs, commits, mutations, deleted, aliases, SOURCE, TREE}; +} + +module.exports = {executionFixture}; diff --git a/.github/workflows/e2e-retarget.yml b/.github/workflows/e2e-retarget.yml index 050098dcd..ea581e416 100644 --- a/.github/workflows/e2e-retarget.yml +++ b/.github/workflows/e2e-retarget.yml @@ -7,8 +7,8 @@ on: description: "Ready PR into master to validate" required: true workflow_ref: - description: "Workflow definitions to use (master after merge)" - default: master + description: "Optional compatible workflow source override (default: this helper revision)" + default: "" suite: description: "Suite to recover (automatic retarget validates both)" type: choice @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest permissions: actions: write - contents: read + contents: write pull-requests: read checks: write concurrency: @@ -38,7 +38,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Test retarget reporting - run: node --test .github/scripts/retarget-tests.test.cjs + run: node --test .github/scripts/retarget-*.test.cjs - name: Start tests and create pending PR checks uses: actions/github-script@v9 with: diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6fa234b19..068e8a595 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -28,6 +28,12 @@ on: retarget_run: description: "Retarget status owner (set by e2e-retarget.yml)" default: "" + workflow_ref: + description: "Workflow source recorded for this validation generation" + default: master + helper_ref: + description: "Recovery helper branch or tag" + default: master # PRs stacked on another branch run no E2E until they target master; e2e-retarget.yml starts it on retarget pull_request: types: [opened, synchronize, reopened, ready_for_review] @@ -110,6 +116,7 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} + RETARGET_HELPER_REF: ${{ inputs.helper_ref }} with: script: | const {reportRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); @@ -245,6 +252,7 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} + RETARGET_HELPER_REF: ${{ inputs.helper_ref }} with: script: | const {reportRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); @@ -421,6 +429,7 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} + RETARGET_HELPER_REF: ${{ inputs.helper_ref }} TEST_RESULTS: ${{ toJSON(needs) }} with: script: | diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index e1e9a2854..02a79cc44 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -25,6 +25,12 @@ on: retarget_run: description: "Retarget status owner (set by e2e-retarget.yml)" default: "" + workflow_ref: + description: "Workflow source recorded for this validation generation" + default: master + helper_ref: + description: "Recovery helper branch or tag" + default: master env: GIT_LFS_SKIP_SMUDGE: "1" @@ -74,6 +80,7 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} + RETARGET_HELPER_REF: ${{ inputs.helper_ref }} with: script: | const {reportRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); @@ -187,6 +194,7 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} + RETARGET_HELPER_REF: ${{ inputs.helper_ref }} TEST_RESULT: ${{ needs.test.result }} with: script: | diff --git a/.github/workflows/retarget-execution-cleanup.yml b/.github/workflows/retarget-execution-cleanup.yml new file mode 100644 index 000000000..8312f868c --- /dev/null +++ b/.github/workflows/retarget-execution-cleanup.yml @@ -0,0 +1,25 @@ +name: Clean up retarget execution refs + +on: + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + +concurrency: + group: retarget-execution-cleanup + cancel-in-progress: false + +permissions: + contents: write + actions: read + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/github-script@v9 + with: + script: | + const {cleanupExecutionRefs} = require('./.github/scripts/retarget-execution.cjs'); + await cleanupExecutionRefs({github, context, core});