From 8b1230f7d9015d5663155e63c6be21b3dcb54414 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 29 Oct 2026 10:25:17 +0100 Subject: [PATCH 1/5] ci: isolate retarget validation executions --- .github/scripts/retarget-execution.cjs | 119 ++++++++++++++++++ .github/scripts/retarget-execution.test.cjs | 114 +++++++++++++++++ .github/scripts/retarget-tests.cjs | 20 ++- .github/scripts/retarget-tests.test.cjs | 46 +++++-- .../test-fixtures/retarget-execution.cjs | 50 ++++++++ .github/workflows/e2e-retarget.yml | 4 +- .github/workflows/e2e-tests.yml | 12 ++ .github/workflows/integration-tests.yml | 10 ++ .../workflows/retarget-execution-cleanup.yml | 25 ++++ 9 files changed, 387 insertions(+), 13 deletions(-) create mode 100644 .github/scripts/retarget-execution.cjs create mode 100644 .github/scripts/retarget-execution.test.cjs create mode 100644 .github/scripts/test-fixtures/retarget-execution.cjs create mode 100644 .github/workflows/retarget-execution-cleanup.yml diff --git a/.github/scripts/retarget-execution.cjs b/.github/scripts/retarget-execution.cjs new file mode 100644 index 000000000..051c1f87c --- /dev/null +++ b/.github/scripts/retarget-execution.cjs @@ -0,0 +1,119 @@ +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, + 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}`}); + for (const ref of refs.slice(0, 100)) { + 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}`}); + 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..87b132d54 --- /dev/null +++ b/.github/scripts/retarget-execution.test.cjs @@ -0,0 +1,114 @@ +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.equal(f.mutations[0].fileChanges, undefined); +}); + +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); +}); diff --git a/.github/scripts/retarget-tests.cjs b/.github/scripts/retarget-tests.cjs index 87c53034f..d01f985ce 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,14 @@ 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 execution = await prepareExecutionRef({github, context, number: original.number, generation, + sourceRef: manual && event.inputs.workflow_ref || 'master'}); + const helperRef = manual ? context.ref?.replace(/^refs\/(heads|tags)\//, '') || 'master' : 'master'; 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,9 +111,17 @@ 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'; + const workflowRef = inputs.RETARGET_WORKFLOW_REF || '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) { + if (error.status !== 404) throw error; + helperRef = 'master'; + } await github.rest.actions.createWorkflowDispatch({ - ...context.repo, workflow_id: 'e2e-retarget.yml', ref: workflowRef, + ...context.repo, workflow_id: 'e2e-retarget.yml', ref: helperRef, inputs: {pr: number, workflow_ref: workflowRef, 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..000df820d 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,6 +11,7 @@ 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 checks = new Map(); @@ -18,8 +20,8 @@ function fixture() { 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 () => { @@ -256,3 +259,32 @@ 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 uses the stable source, never the disposable execution ref', 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_WORKFLOW_REF: f.execution.SOURCE, RETARGET_HELPER_REF: 'ci/fix'}}); + assert.equal(f.dispatches[2].ref, 'ci/fix'); + assert.deepEqual(f.dispatches[2].inputs, {pr: '7', workflow_ref: f.execution.SOURCE, 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']); +}); + +test('a deleted pre-merge helper branch falls back to master while retaining the pinned workflow source', async () => { + const f = await start(fixture()); + await report(f, {result: 'failure'}); + await report(f, {phase: 'start', inputs: {...inputs(), RETARGET_WORKFLOW_REF: f.execution.SOURCE, RETARGET_HELPER_REF: 'deleted-branch'}}); + assert.equal(f.dispatches[2].ref, 'master'); + assert.equal(f.dispatches[2].inputs.workflow_ref, f.execution.SOURCE); +}); diff --git a/.github/scripts/test-fixtures/retarget-execution.cjs b/.github/scripts/test-fixtures/retarget-execution.cjs new file mode 100644 index 000000000..dfb8b4c4c --- /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}}}]]); + 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 missing(); + 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..72732743f 100644 --- a/.github/workflows/e2e-retarget.yml +++ b/.github/workflows/e2e-retarget.yml @@ -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..0a8d5b094 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: "Pinned workflow source for retarget recovery" + 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,8 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} + RETARGET_WORKFLOW_REF: ${{ inputs.workflow_ref }} + RETARGET_HELPER_REF: ${{ inputs.helper_ref }} with: script: | const {reportRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); @@ -245,6 +253,8 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} + RETARGET_WORKFLOW_REF: ${{ inputs.workflow_ref }} + RETARGET_HELPER_REF: ${{ inputs.helper_ref }} with: script: | const {reportRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); @@ -421,6 +431,8 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} + RETARGET_WORKFLOW_REF: ${{ inputs.workflow_ref }} + 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..7f0527188 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: "Pinned workflow source for retarget recovery" + default: master + helper_ref: + description: "Recovery helper branch or tag" + default: master env: GIT_LFS_SKIP_SMUDGE: "1" @@ -74,6 +80,8 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} + RETARGET_WORKFLOW_REF: ${{ inputs.workflow_ref }} + RETARGET_HELPER_REF: ${{ inputs.helper_ref }} with: script: | const {reportRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); @@ -187,6 +195,8 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} + RETARGET_WORKFLOW_REF: ${{ inputs.workflow_ref }} + 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}); From df77920a94d6377bf0ef8402f7a52ea87a1d0675 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 29 Oct 2026 10:34:14 +0100 Subject: [PATCH 2/5] ci: harden execution snapshot creation and cleanup --- .github/scripts/retarget-execution.cjs | 6 +++++- .github/scripts/retarget-execution.test.cjs | 18 +++++++++++++++++- .../test-fixtures/retarget-execution.cjs | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/scripts/retarget-execution.cjs b/.github/scripts/retarget-execution.cjs index 051c1f87c..d55d2e16d 100644 --- a/.github/scripts/retarget-execution.cjs +++ b/.github/scripts/retarget-execution.cjs @@ -62,6 +62,7 @@ async function prepareExecutionRef({github, context, number, generation, sourceR 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. @@ -84,7 +85,9 @@ async function prepareExecutionRef({github, context, number, generation, sourceR 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}`}); - for (const ref of refs.slice(0, 100)) { + 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 { @@ -109,6 +112,7 @@ async function cleanupExecutionRefs({github, context, core, now = Date.now()}) { 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}`); diff --git a/.github/scripts/retarget-execution.test.cjs b/.github/scripts/retarget-execution.test.cjs index 87b132d54..f456d6c89 100644 --- a/.github/scripts/retarget-execution.test.cjs +++ b/.github/scripts/retarget-execution.test.cjs @@ -19,7 +19,7 @@ test('execution uses a distinct verified commit with the trusted workflow tree a assert.equal(commit.commit.tree.sha, f.TREE); assert.equal(commit.commit.verification.verified, true); assert.equal(commit.author.login, 'github-actions[bot]'); - assert.equal(f.mutations[0].fileChanges, undefined); + assert.deepEqual(f.mutations[0].fileChanges, {additions: [], deletions: []}); }); test('an existing generation is reconciled without appending another execution commit', async () => { @@ -112,3 +112,19 @@ test('cleanup refuses changed refs, foreign metadata and unsigned commits', asyn 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/test-fixtures/retarget-execution.cjs b/.github/scripts/test-fixtures/retarget-execution.cjs index dfb8b4c4c..6215d0430 100644 --- a/.github/scripts/test-fixtures/retarget-execution.cjs +++ b/.github/scripts/test-fixtures/retarget-execution.cjs @@ -4,7 +4,7 @@ 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}}}]]); + 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 = []; From 1653817841d4fe83b6b25a1856d1572d71653714 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 29 Oct 2026 11:10:22 +0100 Subject: [PATCH 3/5] ci: recover retries after helper branch deletion --- .github/scripts/retarget-tests.cjs | 3 +- .github/scripts/retarget-tests.test.cjs | 29 ++++++++++++++++--- .../test-fixtures/retarget-execution.cjs | 2 +- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/.github/scripts/retarget-tests.cjs b/.github/scripts/retarget-tests.cjs index d01f985ce..256761a96 100644 --- a/.github/scripts/retarget-tests.cjs +++ b/.github/scripts/retarget-tests.cjs @@ -117,7 +117,8 @@ async function reportRetargetResult({github, context, core, name, phase, result, try { await github.rest.repos.getCommit({...context.repo, ref: helperRef}); } catch (error) { - if (error.status !== 404) throw 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({ diff --git a/.github/scripts/retarget-tests.test.cjs b/.github/scripts/retarget-tests.test.cjs index 000df820d..0730c29b1 100644 --- a/.github/scripts/retarget-tests.test.cjs +++ b/.github/scripts/retarget-tests.test.cjs @@ -281,10 +281,31 @@ test('unsigned execution preparation fails both PR checks and never dispatches t assert.deepEqual([...f.checks.values()].map(c => c.conclusion), ['failure', 'failure']); }); -test('a deleted pre-merge helper branch falls back to master while retaining the pinned workflow source', async () => { +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} on master with the pinned workflow source`, 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_WORKFLOW_REF: f.execution.SOURCE, RETARGET_HELPER_REF: 'deleted-branch'}}); + assert.equal(f.dispatches[2].ref, 'master'); + assert.deepEqual(f.dispatches[2].inputs, {pr: '7', workflow_ref: f.execution.SOURCE, 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'}); - await report(f, {phase: 'start', inputs: {...inputs(), RETARGET_WORKFLOW_REF: f.execution.SOURCE, RETARGET_HELPER_REF: 'deleted-branch'}}); - assert.equal(f.dispatches[2].ref, 'master'); - assert.equal(f.dispatches[2].inputs.workflow_ref, f.execution.SOURCE); + 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'); }); diff --git a/.github/scripts/test-fixtures/retarget-execution.cjs b/.github/scripts/test-fixtures/retarget-execution.cjs index 6215d0430..5bcfe5488 100644 --- a/.github/scripts/test-fixtures/retarget-execution.cjs +++ b/.github/scripts/test-fixtures/retarget-execution.cjs @@ -25,7 +25,7 @@ function executionFixture() { }; const repos = {getCommit: async ({ref}) => { const sha = aliases.get(ref) || refs.get(`heads/${ref}`) || ref; - if (!commits.has(sha)) throw missing(); + 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) => { From 68f21abecdee2737f69c7e40628272e62500b5db Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 29 Oct 2026 12:53:23 +0100 Subject: [PATCH 4/5] ci: align retry workflow definitions with helper --- .github/scripts/retarget-tests.cjs | 10 ++-- .github/scripts/retarget-tests.test.cjs | 67 ++++++++++++++++++++++--- .github/workflows/e2e-retarget.yml | 4 +- .github/workflows/e2e-tests.yml | 2 +- .github/workflows/integration-tests.yml | 2 +- 5 files changed, 71 insertions(+), 14 deletions(-) diff --git a/.github/scripts/retarget-tests.cjs b/.github/scripts/retarget-tests.cjs index 256761a96..5ce72cb8f 100644 --- a/.github/scripts/retarget-tests.cjs +++ b/.github/scripts/retarget-tests.cjs @@ -65,9 +65,12 @@ 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 execution = await prepareExecutionRef({github, context, number: original.number, generation, - sourceRef: manual && event.inputs.workflow_ref || 'master'}); 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({ @@ -111,7 +114,6 @@ 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 = inputs.RETARGET_WORKFLOW_REF || '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 { @@ -123,7 +125,7 @@ async function reportRetargetResult({github, context, core, name, phase, result, } await github.rest.actions.createWorkflowDispatch({ ...context.repo, workflow_id: 'e2e-retarget.yml', ref: helperRef, - inputs: {pr: number, workflow_ref: workflowRef, suite: CHECKS.find(check => check.context === name).suite}, + 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 0730c29b1..890712430 100644 --- a/.github/scripts/retarget-tests.test.cjs +++ b/.github/scripts/retarget-tests.test.cjs @@ -13,7 +13,7 @@ const inputs = (id = 1, generation = '200-1') => ({RETARGET_CHECK_RUN_ID: String 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 = []; @@ -150,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'); @@ -260,13 +260,13 @@ test('only E2E can report not required, with an explicit check explanation', asy assert.equal(f.checks.get(2).conclusion, 'failure'); }); -test('retry recovery uses the stable source, never the disposable execution ref', async () => { +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_WORKFLOW_REF: f.execution.SOURCE, RETARGET_HELPER_REF: 'ci/fix'}}); assert.equal(f.dispatches[2].ref, 'ci/fix'); - assert.deepEqual(f.dispatches[2].inputs, {pr: '7', workflow_ref: f.execution.SOURCE, suite: 'e2e'}); + 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 () => { @@ -282,7 +282,7 @@ test('unsigned execution preparation fails both PR checks and never dispatches t }); 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} on master with the pinned workflow source`, async () => { + 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)); @@ -294,7 +294,7 @@ for (const [suite, name, id, otherId] of [['e2e', E2E, 1, 2], ['integration', IN }; await report(f, {name, phase: 'start', inputs: {...inputs(id), RETARGET_WORKFLOW_REF: f.execution.SOURCE, RETARGET_HELPER_REF: 'deleted-branch'}}); assert.equal(f.dispatches[2].ref, 'master'); - assert.deepEqual(f.dispatches[2].inputs, {pr: '7', workflow_ref: f.execution.SOURCE, suite}); + 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); @@ -309,3 +309,58 @@ for (const status of [403, 429, 500]) test(`helper lookup failure (${status}) do 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_WORKFLOW_REF: f.execution.SOURCE, 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/workflows/e2e-retarget.yml b/.github/workflows/e2e-retarget.yml index 72732743f..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 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 0a8d5b094..9a5a3a0c7 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -29,7 +29,7 @@ on: description: "Retarget status owner (set by e2e-retarget.yml)" default: "" workflow_ref: - description: "Pinned workflow source for retarget recovery" + description: "Workflow source recorded for this validation generation" default: master helper_ref: description: "Recovery helper branch or tag" diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 7f0527188..2f89565e6 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -26,7 +26,7 @@ on: description: "Retarget status owner (set by e2e-retarget.yml)" default: "" workflow_ref: - description: "Pinned workflow source for retarget recovery" + description: "Workflow source recorded for this validation generation" default: master helper_ref: description: "Recovery helper branch or tag" From b7d86f2561dee74454c6252416ae463ce72b59f9 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 29 Oct 2026 13:09:44 +0100 Subject: [PATCH 5/5] ci: remove unused reporter source environment --- .github/scripts/retarget-tests.test.cjs | 6 +++--- .github/workflows/e2e-tests.yml | 3 --- .github/workflows/integration-tests.yml | 2 -- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/scripts/retarget-tests.test.cjs b/.github/scripts/retarget-tests.test.cjs index 890712430..99930059f 100644 --- a/.github/scripts/retarget-tests.test.cjs +++ b/.github/scripts/retarget-tests.test.cjs @@ -264,7 +264,7 @@ test('retry recovery selects the stable helper without forwarding the old pinned 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_WORKFLOW_REF: f.execution.SOURCE, RETARGET_HELPER_REF: 'ci/fix'}}); + 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'}); }); @@ -292,7 +292,7 @@ for (const [suite, name, id, otherId] of [['e2e', E2E, 1, 2], ['integration', IN 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_WORKFLOW_REF: f.execution.SOURCE, RETARGET_HELPER_REF: 'deleted-branch'}}); + 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'); @@ -319,7 +319,7 @@ for (const [suite, name, id, otherId] of [['e2e', E2E, 1, 2], ['integration', IN 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_WORKFLOW_REF: f.execution.SOURCE, RETARGET_HELPER_REF: 'master'}}); + 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; diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9a5a3a0c7..068e8a595 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -116,7 +116,6 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} - RETARGET_WORKFLOW_REF: ${{ inputs.workflow_ref }} RETARGET_HELPER_REF: ${{ inputs.helper_ref }} with: script: | @@ -253,7 +252,6 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} - RETARGET_WORKFLOW_REF: ${{ inputs.workflow_ref }} RETARGET_HELPER_REF: ${{ inputs.helper_ref }} with: script: | @@ -431,7 +429,6 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} - RETARGET_WORKFLOW_REF: ${{ inputs.workflow_ref }} RETARGET_HELPER_REF: ${{ inputs.helper_ref }} TEST_RESULTS: ${{ toJSON(needs) }} with: diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 2f89565e6..02a79cc44 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -80,7 +80,6 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} - RETARGET_WORKFLOW_REF: ${{ inputs.workflow_ref }} RETARGET_HELPER_REF: ${{ inputs.helper_ref }} with: script: | @@ -195,7 +194,6 @@ jobs: RETARGET_HEAD_SHA: ${{ inputs.head_sha }} RETARGET_MERGE_COMMIT: ${{ inputs.merge_commit }} RETARGET_PR: ${{ inputs.pr }} - RETARGET_WORKFLOW_REF: ${{ inputs.workflow_ref }} RETARGET_HELPER_REF: ${{ inputs.helper_ref }} TEST_RESULT: ${{ needs.test.result }} with: