From 12f1838c7b4eae9b5299fdbae4bb2aa2e2c7dea1 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 23 Oct 2026 13:58:22 +0100 Subject: [PATCH] ci: resolve merge conflicts caused by Action pins Microsoft Git requires full commit SHAs for GitHub Actions, while the upstream workflows use symbolic refs. An upstream version update can therefore conflict with downstream pinning even when both changes can be preserved: keeping our side loses the update, while taking theirs drops the required pin. Use the normal merge machinery on unpinned downstream contents, then pin the merged workflows again. Emulating the pin reversals and remerge with merge-tree avoids choosing one conflicted side or guessing how to edit markers, and preserves other downstream customizations. Require the index and working tree to match an untouched ordinary conflicted merge before installing the result. Refuse if unpinning leaves any conflicts, since installing those files as an ordinary tree would incorrectly mark unrelated paths as resolved. A successful resolution is staged with the original merge parents intact, leaving the final merge commit to the maintainer. The new path accepts only conflicted two-parent merges; clean merges paused before committing are rejected as well. The existing pin-refresh and commit behavior remains unchanged when no merge is in progress. Assisted-by: GPT-6.1 Sol Signed-off-by: Matthew John Cheetham --- .github/pin-github-actions-shas.js | 204 ++++++++++++++++++++++-- .github/pin-github-actions-shas.test.js | 168 ++++++++++++++++++- 2 files changed, 356 insertions(+), 16 deletions(-) diff --git a/.github/pin-github-actions-shas.js b/.github/pin-github-actions-shas.js index 90a662c284b2bf..726b5658fbec37 100755 --- a/.github/pin-github-actions-shas.js +++ b/.github/pin-github-actions-shas.js @@ -2,6 +2,8 @@ const { execFileSync } = require('node:child_process'); const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); const pinningMessage = `ci: pin GitHub Actions to satisfy downstream policy @@ -13,12 +15,44 @@ Keep pins aligned with the original upstream Action refs in a fresh downstream snapshot, independent of any earlier pinning commit. `; -function git(...args) { +function gitOutput(args, options = {}) { return execFileSync('git', ['--no-pager', ...args], { encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - maxBuffer: 16 * 1024 * 1024 - }).trim(); + stdio: ['pipe', 'pipe', 'pipe'], + maxBuffer: 16 * 1024 * 1024, + ...options + }); +} + +function git(...args) { + return gitOutput(args).trim(); +} + +function withTemporaryIndex(callback) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'pin-index-')); + const index = path.join(directory, 'index'); + const env = { ...process.env, GIT_INDEX_FILE: index }; + try { + return callback((args, options = {}) => + gitOutput(args, { ...options, env }), index); + } finally { + fs.rmSync(directory, { recursive: true }); + } +} + +function mergeTree(...args) { + let output, clean = true; + try { + output = gitOutput(['merge-tree', '--write-tree', + '--no-messages', '-z', ...args]); + } catch (error) { + if (error.status !== 1) + throw error; + output = error.stdout; + clean = false; + } + const [tree, ...conflicts] = output.split('\0'); + return { tree, conflicts: conflicts.filter(Boolean), clean }; } const isSHA = value => /^[0-9a-f]{40}$/i.test(value); @@ -43,15 +77,17 @@ function findOriginalActionRef(file, actionName, workflowHistory) { throw new Error(`Cannot recover Action ref: ${file}, ${actionName}`); } -function readWorkflow(file) { - const text = fs.readFileSync(file, 'utf8'); +function readWorkflow(file, tree) { + const text = tree ? gitOutput(['show', `${tree}:${file}`]) : + fs.readFileSync(file, 'utf8'); const parts = text.split(/(^ *-? *uses:[^\r\n]*)/m); const matches = parts.map(part => part.match(useLine)) .filter(use => use && !use[3].startsWith('./')); const workflowHistory = matches.some(use => isSHA(use[4])) ? git('log', '--follow', '-p', '-U0', '-Guses:.*@', '--no-ext-diff', '--no-textconv', - '--format=%x00', '--', file) + '--format=%x00', + ...(tree ? ['HEAD', 'MERGE_HEAD'] : []), '--', file) : ''; const actionRefs = matches.map(use => { const actionName = use[3]; @@ -104,22 +140,156 @@ function substitute({ file, text, parts, actionRefs }, pins) { return updated === text ? null : [file, updated]; } -try { - process.chdir(git('rev-parse', '--show-toplevel')); - const files = git('ls-files', '-z', '--', '.github/workflows') +function pinWorkflows(tree) { + const files = (tree ? git('ls-tree', '-r', '--name-only', '-z', + tree, '--', '.github/workflows') : + git('ls-files', '-z', '--', '.github/workflows')) .split('\0').filter(file => /\.ya?ml$/.test(file)); - if (git('status', '--porcelain', '--untracked-files=no', - '--', '.github/workflows')) - throw new Error('Workflow files have uncommitted changes'); - const workflows = files.map(readWorkflow); + const workflows = files.map(file => readWorkflow(file, tree)); const actionRefs = workflows.flatMap(workflow => workflow.actionRefs); const uniquePins = new Map(actionRefs.map(actionRef => [actionRef.pinKey, actionRef])); const pins = Object.fromEntries([...uniquePins.values()].map( ({ pinKey, actionName, version }) => [pinKey, resolveActionSHA(actionName, version)])); - const changed = workflows.map(workflow => substitute(workflow, pins)) + return workflows.map(workflow => substitute(workflow, pins)) .filter(Boolean); +} + +function validateMerge(merge, autoMerge) { + const mismatch = () => { + throw new Error('Current merge does not match ' + + 'git merge-tree HEAD MERGE_HEAD'); + }; + if (merge.clean) + mismatch(); + const entryPath = entry => entry.slice(entry.indexOf('\t') + 1); + const paths = new Set(merge.conflicts.map(entryPath)); + const expected = gitOutput(['ls-tree', '-r', '-z', merge.tree]) + .split('\0').filter(Boolean) + .filter(entry => !paths.has(entryPath(entry))) + .map(entry => entry.replace(/^(\d+) \w+ (\w+)\t/, + '$1 $2 0\t')) + .concat(merge.conflicts).sort().join('\0'); + const actual = gitOutput(['ls-files', '--stage', '-z']) + .split('\0').filter(Boolean).sort().join('\0'); + if (expected !== actual) + mismatch(); + + // merge-tree uses ref names, not the original merge's marker labels. + const labels = text => text.replace( + /^(<+|\|+|>+) [^\r\n]*/gm, '$1'); + const differences = gitOutput(['diff-tree', '--raw', '-r', '-z', + '--no-renames', '--no-abbrev', + merge.tree, autoMerge]).split('\0'); + for (let i = 0; i < differences.length - 1; i += 2) { + const [oldMode, mode, before, after, status] = + differences[i].split(' '); + if (oldMode.slice(1) !== mode || status !== 'M' || + !paths.has(differences[i + 1])) + mismatch(); + if (labels(gitOutput(['cat-file', 'blob', before])) !== + labels(gitOutput(['cat-file', 'blob', after]))) + mismatch(); + } + withTemporaryIndex(indexGit => { + indexGit(['read-tree', autoMerge]); + try { + indexGit(['update-index', '--refresh']); + } catch (error) { + if (error.status !== 1) + throw error; + mismatch(); + } + }); +} + +function resolveMerge() { + const head = git('rev-parse', 'HEAD'); + const mergeHeadFile = git('rev-parse', '--git-path', 'MERGE_HEAD'); + const mergeHead = fs.readFileSync(mergeHeadFile, 'utf8').trim(); + if (mergeHead.includes('\n')) + throw new Error('Only two-parent merges are supported'); + const merge = mergeTree('HEAD', 'MERGE_HEAD'); + const autoMerge = git('rev-parse', '--verify', 'AUTO_MERGE^{tree}'); + validateMerge(merge, autoMerge); + const pinningSubject = pinningMessage.split('\n')[0]; + const pinningCommits = git('log', '--topo-order', '--no-merges', + '--format=%H%x00%s', `--grep=^${pinningSubject}$`, + 'HEAD', '--not', 'MERGE_HEAD', '--') + .split('\n').map(line => line.split('\0')) + .filter(([, subject]) => subject === pinningSubject) + .map(([commit]) => commit); + if (!pinningCommits.length) + throw new Error('Cannot find downstream Action pinning commit'); + let tree = 'HEAD'; + for (const commit of pinningCommits) { + const reverted = mergeTree(`--merge-base=${commit}`, + tree, `${commit}^`); + if (!reverted.clean) + throw new Error('Cannot cleanly revert ' + + 'Action pinning commit ' + commit); + tree = reverted.tree; + } + // Preserve HEAD's ancestry, including multiple possible merge bases. + const unpinned = git('commit-tree', tree, '-p', 'HEAD', + '-m', 'Unpin GitHub Actions for merge-tree'); + const remerged = mergeTree(unpinned, 'MERGE_HEAD'); + if (!remerged.clean) + throw new Error('Merge still has conflicts ' + + 'without Action pins'); + const changed = pinWorkflows(remerged.tree); + withTemporaryIndex((indexGit, temporaryIndex) => { + indexGit(['read-tree', remerged.tree]); + for (const [file, text] of changed) { + const mode = indexGit(['ls-files', '--stage', + '--', file]).split(' ')[0]; + const blob = gitOutput(['hash-object', '-w', '--stdin'], + { input: text }).trim(); + indexGit(['update-index', '--cacheinfo', + mode, blob, file]); + } + const resolution = indexGit(['write-tree']).trim(); + indexGit(['read-tree', autoMerge]); + indexGit(['update-index', '--refresh']); + const index = path.resolve( + git('rev-parse', '--git-path', 'index')); + const lock = `${index}.lock`; + fs.copyFileSync(temporaryIndex, lock, + fs.constants.COPYFILE_EXCL); + try { + const currentMergeHead = fs.readFileSync( + mergeHeadFile, 'utf8').trim(); + if (git('rev-parse', 'HEAD') !== head || + currentMergeHead !== mergeHead) + throw new Error('Merge changed while ' + + 'resolving Action pins'); + validateMerge(merge, autoMerge); + + // Read from the lock so Git can atomically install the + // index while retaining its worktree safety checks. + gitOutput(['read-tree', '-m', '-u', + `--index-output=${index}`, + autoMerge, resolution], { + env: { ...process.env, GIT_INDEX_FILE: lock } + }); + } finally { + fs.unlinkSync(lock); + } + }); + console.log('Resolved merge conflicts with refreshed Action pins.'); +} + +function main() { + process.chdir(git('rev-parse', '--show-toplevel')); + if (fs.existsSync(git('rev-parse', '--git-path', 'MERGE_HEAD'))) { + resolveMerge(); + return; + } + if (git('status', '--porcelain', '--untracked-files=no', + '--', '.github/workflows')) + throw new Error('Workflow files have uncommitted changes'); + const changed = pinWorkflows(); if (changed.length) { const message = git('rev-parse', '--git-path', 'COMMIT_EDITMSG'); @@ -131,6 +301,10 @@ try { } else { console.log('Action pins are up to date.'); } +} + +try { + main(); } catch (error) { console.error(`error: ${error.message}`); process.exitCode = 1; diff --git a/.github/pin-github-actions-shas.test.js b/.github/pin-github-actions-shas.test.js index da21c74688dffb..4372669a00dd76 100644 --- a/.github/pin-github-actions-shas.test.js +++ b/.github/pin-github-actions-shas.test.js @@ -6,7 +6,7 @@ const path = require('node:path'); const { test } = require('node:test'); const { pathToFileURL } = require('node:url'); -test('fresh pinning commits preserve unrelated work', t => { +function setupRepository(t) { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'pin test-')); t.after(() => fs.rmSync(directory, { recursive: true })); const env = Object.fromEntries(Object.entries(process.env) @@ -28,6 +28,12 @@ test('fresh pinning commits preserve unrelated work', t => { const run = () => execFileSync(process.execPath, [path.join(__dirname, 'pin-github-actions-shas.js')], { cwd: directory, env, stdio: ['ignore', 'pipe', 'pipe'] }); + return { directory, git, workflows, file, write, read, run }; +} + +test('fresh pinning commits preserve unrelated work', t => { + const { directory, git, workflows, write, read, run } = + setupRepository(t); const workflow = `on: push jobs: test: @@ -86,3 +92,163 @@ jobs: assert.equal(git('diff', '--cached', '--name-only'), 'unrelated'); assert.equal(git('show', 'HEAD:unrelated'), 'original'); }); + +function setupConflictedMerge(t, { refresh = false, conflict = false, + changedPins = false, style = 'merge', markerSize = 7 } = {}) { + const repository = setupRepository(t); + const { directory, git, workflows, write, read, run } = repository; + const workflow = `on: push +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: example/action@v1 +`.replace(/\n/g, '\r\n'); + write(workflow); + const deleted = path.join(workflows, 'deleted.yml'); + fs.writeFileSync(deleted, workflow); + const unrelated = path.join(directory, 'unrelated'); + fs.writeFileSync(unrelated, 'original\n'); + git('add', '--', '.github/workflows', 'unrelated'); + git('commit', '-qm', 'Upstream workflows'); + git('tag', 'v1'); + git('branch', 'upstream'); + git('config', `url.${pathToFileURL(directory).href}.insteadOf`, + 'https://github.com/example/action'); + run(); + if (refresh) { + git('tag', '-f', 'v1', 'HEAD'); + run(); + } + if (changedPins) { + write(read().replace('# v1', '# v1; downstream note')); + git('commit', '-qam', 'Annotate downstream action'); + } + fs.writeFileSync(path.join(directory, 'downstream'), 'keep me\n'); + if (conflict) + fs.writeFileSync(unrelated, 'downstream\n'); + git('add', '--', 'downstream', 'unrelated'); + git('commit', '-qm', 'Downstream changes'); + const head = git('rev-parse', 'HEAD'); + git('switch', '-q', 'upstream'); + write(workflow.replace('@v1', '@v2')); + fs.writeFileSync(path.join(workflows, 'new workflow.yaml'), + workflow.replace('@v1', '@v2')); + fs.unlinkSync(deleted); + fs.writeFileSync(unrelated, 'upstream\n'); + git('add', '--', '.github/workflows', 'unrelated'); + git('commit', '-qm', 'Update upstream workflows'); + git('tag', 'v2'); + const upstream = git('rev-parse', 'HEAD'); + git('switch', '-q', 'test'); + git('config', 'merge.conflictStyle', style); + fs.writeFileSync(path.join(directory, '.git/info/attributes'), + `.github/workflows/* conflict-marker-size=${markerSize}\n`); + assert.throws(() => git('merge', '--no-edit', 'upstream'), + error => error.status === 1); + const snapshot = () => ({ + head: git('rev-parse', 'HEAD'), + mergeHead: git('rev-parse', 'MERGE_HEAD'), + index: git('ls-files', '--stage', '-z'), + workflow: read(), + unrelated: fs.readFileSync(unrelated, 'utf8') + }); + return { ...repository, workflow, head, upstream, unrelated, snapshot }; +} + +test('revert, remerge, and repin resolve the whole merge tree', t => { + const { directory, git, workflows, read, run, workflow, head, + upstream } = setupConflictedMerge(t, { refresh: true }); + const untracked = path.join(directory, 'untracked'); + fs.writeFileSync(untracked, 'leave me alone\n'); + run(); + const expected = workflow.replace('@v1', `@${upstream} # v2`); + assert.equal(read(), expected); + assert.equal(fs.readFileSync( + path.join(workflows, 'new workflow.yaml'), 'utf8'), expected); + assert.equal(fs.existsSync(path.join(workflows, 'deleted.yml')), false); + assert.equal(fs.readFileSync( + path.join(directory, 'downstream'), 'utf8'), 'keep me\n'); + assert.equal(fs.readFileSync(path.join(directory, 'unrelated'), 'utf8'), + 'upstream\n'); + assert.equal(fs.readFileSync(untracked, 'utf8'), 'leave me alone\n'); + assert.equal(git('ls-files', '--unmerged'), ''); + assert.equal(git('diff', '--name-only'), ''); + assert.equal(git('rev-parse', 'HEAD'), head); + assert.equal(git('rev-parse', 'MERGE_HEAD'), upstream); + assert.equal(git('diff', '--cached', '--name-only'), + '.github/workflows/deleted.yml\n' + + '.github/workflows/main.yml\n' + + '.github/workflows/new workflow.yaml\nunrelated'); +}); + +test('merge validation accepts configured conflict markers', async t => { + for (const [style, markerSize] of [['diff3', 7], ['zdiff3', 3]]) { + await t.test(style, t => { + const { git, read, run, workflow, upstream } = + setupConflictedMerge(t, { style, markerSize }); + run(); + assert.equal(read(), + workflow.replace('@v1', `@${upstream} # v2`)); + assert.equal(git('ls-files', '--unmerged'), ''); + }); + } +}); + +test('edited merge state is never overwritten', async t => { + const changes = { + 'edited conflict': ({ file }) => + fs.appendFileSync(file, '# manual edit\n'), + 'staged resolution': ({ git }) => + git('add', '--', '.github/workflows/main.yml'), + 'unstaged unrelated edit': ({ unrelated }) => + fs.writeFileSync(unrelated, 'manual edit\n'), + 'staged unrelated edit': ({ git, unrelated }) => { + fs.writeFileSync(unrelated, 'manual edit\n'); + git('add', '--', 'unrelated'); + } + }; + for (const [name, change] of Object.entries(changes)) { + await t.test(name, t => { + const repository = setupConflictedMerge(t); + change(repository); + const before = repository.snapshot(); + assert.throws(repository.run, + /Current merge does not match/); + assert.deepEqual(repository.snapshot(), before); + }); + } +}); + +test('conflicts remaining after unpinning are not resolved', t => { + const { run, snapshot } = setupConflictedMerge(t, { conflict: true }); + const before = snapshot(); + assert.throws(run, /Merge still has conflicts without Action pins/); + assert.deepEqual(snapshot(), before); +}); + +test('conflicting pin reverts leave the merge untouched', t => { + const { run, snapshot } = + setupConflictedMerge(t, { changedPins: true }); + const before = snapshot(); + assert.throws(run, /Cannot cleanly revert Action pinning commit/); + assert.deepEqual(snapshot(), before); +}); + +test('failed Action resolution leaves the merge untouched', t => { + const { git, run, snapshot } = setupConflictedMerge(t); + git('tag', '-d', 'v2'); + const before = snapshot(); + assert.throws(run, /Cannot resolve Action ref: example\/action@v2/); + assert.deepEqual(snapshot(), before); +}); + +test('a locked index leaves the merge untouched', t => { + const { directory, run, snapshot } = setupConflictedMerge(t); + const before = snapshot(); + const lock = path.join(directory, '.git/index.lock'); + fs.writeFileSync(lock, 'another Git operation\n'); + assert.throws(run, /index\.lock/); + assert.deepEqual(snapshot(), before); + assert.equal(fs.readFileSync(lock, 'utf8'), 'another Git operation\n'); +});