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'); +});