Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions .github/pin-github-actions-shas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env node

const { execFileSync } = require('node:child_process');
const fs = require('node:fs');

const pinningMessage = `ci: pin GitHub Actions to satisfy downstream policy

Microsoft Git inherits workflows that upstream maintains using symbolic
Action refs. Downstream organizational policy requires full commit IDs
instead, adding pin maintenance to this project's maintainers' workload.

Keep pins aligned with the original upstream Action refs in a fresh
downstream snapshot, independent of any earlier pinning commit.
`;

function git(...args) {
return execFileSync('git', ['--no-pager', ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 16 * 1024 * 1024
}).trim();
}

const isSHA = value => /^[0-9a-f]{40}$/i.test(value);
const uses = /^( *-? *uses: *)(["']?)([\w./-]+)@([^ \t"'\r\n]+)\2([^\r\n]*)/gm;
const useLine = new RegExp(uses.source);

function findOriginalActionRef(file, actionName, workflowHistory) {
for (const patch of workflowHistory.split('\0')) {
const removed = patch.replace(/^-/gm, '');
const versions = [...removed.matchAll(uses)]
.filter(use => use[3] === actionName &&
!isSHA(use[4]))
.map(use => use[4]);
if (!versions.length)
continue;
const [version] = versions;
if (versions.some(candidate => candidate !== version))
throw new Error('Ambiguous Action ref: ' +
`${file}, ${actionName}`);
return version;
}
throw new Error(`Cannot recover Action ref: ${file}, ${actionName}`);
}

function readWorkflow(file) {
const text = 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)
: '';
const actionRefs = matches.map(use => {
const actionName = use[3];
const version = isSHA(use[4]) ?
findOriginalActionRef(
file, actionName, workflowHistory) :
use[4];
const actionRepo = actionName.split('/').slice(0, 2).join('/');
return {
use, actionName, version,
pinKey: `${actionRepo}@${version}`
};
});
return { file, text, parts, actionRefs };
}

function resolveActionSHA(actionName, version) {
const actionRepo = actionName.split('/').slice(0, 2).join('/');
const actionTagRef = `refs/tags/${version}`;
const output = git('ls-remote', '--quiet',
`https://github.com/${actionRepo}`,
actionTagRef, `${actionTagRef}^{}`, `refs/heads/${version}`);
const actionRepoRefs = Object.fromEntries(output.split('\n')
.map(line => line.split('\t').reverse()));
const sha = actionRepoRefs[`${actionTagRef}^{}`] ||
actionRepoRefs[actionTagRef] ||
actionRepoRefs[`refs/heads/${version}`];
if (!sha || !isSHA(sha))
throw new Error('Cannot resolve Action ref: ' +
`${actionName}@${version}`);
return sha;
}

function substitute({ file, text, parts, actionRefs }, pins) {
const replacements = new Map(actionRefs.map(actionRef => {
const { use, version, pinKey } = actionRef;
const [, prefix, quote, actionName, , suffix] = use;
const sha = pins[pinKey];
let note = suffix.trim().replace(/^#\s*/, '');
if (note.startsWith(`${version}; `))
note = note.slice(version.length + 2);
else if (note === version || /^v?\d[\w.+-]*$/.test(note))
note = '';
const pinned = `${prefix}${quote}${actionName}@${sha}${quote}` +
` # ${version}` + (note ? `; ${note}` : '');
return [use[0], pinned];
}));
const updated = parts.map(part => replacements.get(part) ?? part)
.join('');
return updated === text ? null : [file, updated];
}

try {
process.chdir(git('rev-parse', '--show-toplevel'));
const files = 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 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))
.filter(Boolean);
if (changed.length) {
const message = git('rev-parse', '--git-path',
'COMMIT_EDITMSG');
fs.writeFileSync(message, pinningMessage);
for (const [file, text] of changed)
fs.writeFileSync(file, text);
console.log(git('commit', '--signoff', '-F', message,
'--', ...changed.map(([file]) => file)));
} else {
console.log('Action pins are up to date.');
}
} catch (error) {
console.error(`error: ${error.message}`);
process.exitCode = 1;
}
88 changes: 88 additions & 0 deletions .github/pin-github-actions-shas.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const { pathToFileURL } = require('node:url');

test('fresh pinning commits preserve unrelated work', 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)
.filter(([key]) => !/^GIT_/i.test(key)));
env.GIT_CONFIG_NOSYSTEM = '1';
env.GIT_CONFIG_GLOBAL = os.devNull;
const git = (...args) => execFileSync('git', args, {
cwd: directory, env, encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim();
git('init', '-q', '--initial-branch=test', '--object-format=sha1');
git('config', 'user.name', 'Pin Test');
git('config', 'user.email', 'pins@example.com');
const workflows = path.join(directory, '.github/workflows');
fs.mkdirSync(workflows, { recursive: true });
const file = path.join(workflows, 'main.yml');
const write = text => fs.writeFileSync(file, text);
const read = () => fs.readFileSync(file, 'utf8');
const run = () => execFileSync(process.execPath,
[path.join(__dirname, 'pin-github-actions-shas.js')],
{ cwd: directory, env, stdio: ['ignore', 'pipe', 'pipe'] });
const workflow = `on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: 'example/action@v1'
- uses: example/action/subpath@v2
if: false
- uses: ./local-action
reusable:
uses: example/action/.github/workflows/test.yaml@master
`.replace(/\n/g, '\r\n');
write(workflow);
const another = path.join(workflows, 'another.yaml');
fs.writeFileSync(another, workflow.replace('@v1', '@v2'));
const unrelated = path.join(directory, 'unrelated');
fs.writeFileSync(unrelated, 'original\n');
git('add', '--', '.github/workflows', 'unrelated');
git('commit', '-qm', 'Upstream workflows');
const first = git('rev-parse', 'HEAD');
git('tag', 'v1');
git('tag', '-am', 'Annotated version', 'v2');
git('commit', '--allow-empty', '-qm', 'Update action');
const second = git('rev-parse', 'HEAD');
const pinned = v1 => workflow
.replace(/@v1'/, `@${v1}' # v1`)
.replace('@v2', `@${first} # v2`)
.replace('@master', `@${second} # master`);
git('branch', 'master');
git('branch', 'v1');
git('config', `url.${pathToFileURL(directory).href}.insteadOf`,
'https://github.com/example/action');
fs.writeFileSync(unrelated, 'staged change\n');
git('add', '--', 'unrelated');
const expectedSubject =
'ci: pin GitHub Actions to satisfy downstream policy';
run();
assert.equal(git('log', '-1', '--format=%s'), expectedSubject);
assert.equal(git('rev-parse', 'HEAD^'), second);
assert.equal(read(), pinned(first));
assert.match(git('log', '-1', '--format=%B'),
/Signed-off-by: Pin Test <pins@example.com>/);
write(read().replace(/# v[12]/g, '# v0.0.0'));
git('commit', '-qm', 'Release labels', '--', '.github/workflows');
git('tag', '-f', 'v1', second);
const beforeRefresh = git('rev-parse', 'HEAD');
run();
assert.equal(git('log', '-1', '--format=%s'), expectedSubject);
assert.equal(git('rev-parse', 'HEAD^'), beforeRefresh);
assert.equal(read(), pinned(second));
assert.equal(fs.readFileSync(another, 'utf8'),
pinned(first).replace('# v1', '# v2'));
const head = git('rev-parse', 'HEAD');
run();
assert.equal(git('rev-parse', 'HEAD'), head);
assert.equal(git('diff', '--cached', '--name-only'), 'unrelated');
assert.equal(git('show', 'HEAD:unrelated'), 'original');
});
2 changes: 1 addition & 1 deletion .github/workflows/check-style.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
jobname: ClangFormat
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/check-whitespace.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
check-whitespace:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0

Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:

steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6

- name: Install dependencies
run: ci/install-dependencies.sh
Expand All @@ -34,7 +34,7 @@ jobs:

# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
Expand All @@ -46,7 +46,7 @@ jobs:
make -j$(nproc)

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4
with:
upload: False
output: sarif-results
Expand All @@ -56,12 +56,12 @@ jobs:
run: ls -la sarif-results

- name: publish sarif for debugging
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: sarif-results-${{ matrix.language }}
path: sarif-results

- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v4
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4
with:
sarif_file: sarif-results/${{ matrix.language }}.sarif
8 changes: 4 additions & 4 deletions .github/workflows/coverity.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ jobs:
COVERITY_PLATFORM: overridden-below
NO_RUST: Yup
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: install minimal Git for Windows SDK
if: contains(matrix.os, 'windows')
uses: git-for-windows/setup-git-for-windows-sdk@v2
uses: git-for-windows/setup-git-for-windows-sdk@aed153d1258c311b680b81584a72a1a4807401f0 # v2
- run: ci/install-dependencies.sh
if: contains(matrix.os, 'ubuntu') || contains(matrix.os, 'macos')
env:
Expand Down Expand Up @@ -99,7 +99,7 @@ jobs:
# A cache miss will add ~30s to create, but a cache hit will save minutes.
- name: restore the Coverity Build Tool
id: cache
uses: actions/cache/restore@v6
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ runner.temp }}/cov-analysis
key: cov-build-${{ env.COVERITY_LANGUAGE }}-${{ env.COVERITY_PLATFORM }}-${{ steps.lookup.outputs.hash }}
Expand Down Expand Up @@ -142,7 +142,7 @@ jobs:
esac
- name: cache the Coverity Build Tool
if: steps.cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with:
path: ${{ runner.temp }}/cov-analysis
key: cov-build-${{ env.COVERITY_LANGUAGE }}-${{ env.COVERITY_PLATFORM }}-${{ steps.lookup.outputs.hash }}
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/l10n.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:
origin \
${{ github.ref }} \
$args
- uses: actions/setup-go@v6
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: '>=1.16'
cache: false
Expand Down Expand Up @@ -92,7 +92,7 @@ jobs:
cat git-po-helper.out
exit $exit_code
- name: Create comment in pull request for report
uses: mshick/add-pr-comment@v3
uses: mshick/add-pr-comment@ec328af66588ab8f77cdeb2c264f14aba45bbf59 # v3
if: >-
always() &&
github.event_name == 'pull_request_target' &&
Expand Down
Loading
Loading