Skip to content

Commit 4cc963a

Browse files
authored
Merge pull request #2 from auths-dev/dev-signCommits
feat: add commit signing support
2 parents 464e818 + 1207f1f commit 4cc963a

File tree

5 files changed

+443
-185
lines changed

5 files changed

+443
-185
lines changed

action.yml

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
name: 'Sign Artifacts with Auths'
2-
description: 'Sign build artifacts using Auths identity keys in CI'
1+
name: 'Sign with Auths'
2+
description: 'Sign build artifacts and/or commits using Auths identity keys in CI'
33
author: 'auths'
44

55
inputs:
@@ -25,7 +25,12 @@ inputs:
2525
default: ''
2626
files:
2727
description: 'Glob patterns for files to sign, one per line'
28-
required: true
28+
required: false
29+
default: ''
30+
commits:
31+
description: 'Git revision range to sign (e.g., HEAD~1..HEAD). Merge commits are skipped.'
32+
required: false
33+
default: ''
2934
verify:
3035
description: 'Verify each file after signing using the verify bundle from the token'
3136
required: false
@@ -48,8 +53,10 @@ outputs:
4853
description: 'JSON array of file paths that were signed'
4954
attestation-files:
5055
description: 'JSON array of attestation file paths (.auths.json)'
56+
signed-commits:
57+
description: 'JSON array of signed commit SHAs'
5158
verified:
52-
description: 'Whether all signed files passed verification (empty string if verify was not requested)'
59+
description: 'Whether all signed artifacts/commits passed verification (empty string if verify was not requested)'
5360

5461
runs:
5562
using: 'node20'

dist/index.js

Lines changed: 191 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -67353,6 +67353,12 @@ const token_1 = __nccwpck_require__(94606);
6735367353
async function run() {
6735467354
let credentials = null;
6735567355
try {
67356+
// Validate: at least one of files or commits must be provided
67357+
const filePatterns = core.getMultilineInput('files').filter(p => p.trim());
67358+
const commitsRange = core.getInput('commits').trim();
67359+
if (filePatterns.length === 0 && !commitsRange) {
67360+
throw new Error('At least one of `files` or `commits` must be provided');
67361+
}
6735667362
// 1. Resolve credentials
6735767363
credentials = await (0, token_1.resolveCredentials)();
6735867364
core.info('Credentials resolved');
@@ -67362,91 +67368,173 @@ async function run() {
6736267368
if (!authsPath) {
6736367369
throw new Error('Failed to find or install auths CLI');
6736467370
}
67365-
// 3. Glob match files
67366-
const filePatterns = core.getMultilineInput('files', { required: true });
67367-
const patterns = filePatterns.join('\n');
67368-
const globber = await glob.create(patterns, { followSymbolicLinks: false });
67369-
let files = await globber.glob();
67370-
// Workspace containment check
67371-
const workspace = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd());
67372-
files = files.filter(f => {
67373-
const resolved = path.resolve(f);
67374-
if (!resolved.startsWith(workspace + path.sep) && resolved !== workspace) {
67375-
core.warning(`Skipping path outside workspace: ${f}`);
67376-
return false;
67377-
}
67378-
return true;
67379-
});
67380-
files = [...new Set(files)];
67381-
if (files.length === 0) {
67382-
throw new Error('No files matched the provided glob patterns');
67383-
}
67384-
core.info(`Found ${files.length} file(s) to sign`);
67385-
// 4. Sign each file
6738667371
const deviceKey = core.getInput('device-key') || 'ci-release-device';
6738767372
const note = core.getInput('note') || '';
67373+
const shouldVerify = core.getInput('verify') === 'true';
67374+
let allVerified = true;
6738867375
const signedFiles = [];
6738967376
const attestationFiles = [];
67390-
for (const file of files) {
67391-
core.info(`Signing: ${path.basename(file)}`);
67392-
const cliArgs = [
67393-
'artifact', 'sign', file,
67394-
'--device-key', deviceKey,
67395-
'--repo', credentials.identityRepoPath,
67396-
];
67397-
if (note)
67398-
cliArgs.push('--note', note);
67399-
const exitCode = await exec.exec(authsPath, cliArgs, {
67400-
env: {
67401-
...process.env,
67402-
AUTHS_PASSPHRASE: credentials.passphrase,
67403-
AUTHS_KEYCHAIN_BACKEND: 'file',
67404-
AUTHS_KEYCHAIN_FILE: credentials.keychainPath,
67405-
},
67406-
ignoreReturnCode: true,
67377+
const signedCommits = [];
67378+
const authsEnv = {
67379+
...process.env,
67380+
AUTHS_PASSPHRASE: credentials.passphrase,
67381+
AUTHS_KEYCHAIN_BACKEND: 'file',
67382+
AUTHS_KEYCHAIN_FILE: credentials.keychainPath,
67383+
};
67384+
// 3. Sign artifacts (if files provided)
67385+
if (filePatterns.length > 0) {
67386+
const patterns = filePatterns.join('\n');
67387+
const globber = await glob.create(patterns, { followSymbolicLinks: false });
67388+
let files = await globber.glob();
67389+
// Workspace containment check
67390+
const workspace = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd());
67391+
files = files.filter(f => {
67392+
const resolved = path.resolve(f);
67393+
if (!resolved.startsWith(workspace + path.sep) && resolved !== workspace) {
67394+
core.warning(`Skipping path outside workspace: ${f}`);
67395+
return false;
67396+
}
67397+
return true;
6740767398
});
67408-
if (exitCode !== 0) {
67409-
throw new Error(`Failed to sign ${path.basename(file)} (exit code ${exitCode})`);
67399+
files = [...new Set(files)];
67400+
if (files.length === 0) {
67401+
core.warning('No files matched the provided glob patterns');
6741067402
}
67411-
const attestationPath = `${file}.auths.json`;
67412-
if (!fs.existsSync(attestationPath)) {
67413-
throw new Error(`Signing succeeded but attestation file not found: ${attestationPath}`);
67403+
else {
67404+
core.info(`Found ${files.length} file(s) to sign`);
67405+
for (const file of files) {
67406+
core.info(`Signing: ${path.basename(file)}`);
67407+
const cliArgs = [
67408+
'artifact', 'sign', file,
67409+
'--device-key', deviceKey,
67410+
'--repo', credentials.identityRepoPath,
67411+
];
67412+
if (note)
67413+
cliArgs.push('--note', note);
67414+
const exitCode = await exec.exec(authsPath, cliArgs, {
67415+
env: authsEnv,
67416+
ignoreReturnCode: true,
67417+
});
67418+
if (exitCode !== 0) {
67419+
throw new Error(`Failed to sign ${path.basename(file)} (exit code ${exitCode})`);
67420+
}
67421+
const attestationPath = `${file}.auths.json`;
67422+
if (!fs.existsSync(attestationPath)) {
67423+
throw new Error(`Signing succeeded but attestation file not found: ${attestationPath}`);
67424+
}
67425+
signedFiles.push(file);
67426+
attestationFiles.push(attestationPath);
67427+
core.info(`\u2713 ${path.basename(file)} -> ${path.basename(attestationPath)}`);
67428+
}
6741467429
}
67415-
signedFiles.push(file);
67416-
attestationFiles.push(attestationPath);
67417-
core.info(`\u2713 ${path.basename(file)} -> ${path.basename(attestationPath)}`);
6741867430
}
67419-
// 5. Optionally verify
67420-
const shouldVerify = core.getInput('verify') === 'true';
67421-
let allVerified = true;
67422-
if (shouldVerify) {
67423-
if (!credentials.verifyBundlePath) {
67424-
core.warning('verify: true requested but no verify bundle available. Provide verify_bundle in the CiToken or set the verify-bundle input.');
67425-
allVerified = false;
67431+
// 4. Sign commits (if commits range provided)
67432+
if (commitsRange) {
67433+
core.info('');
67434+
core.info('=== Commit Signing ===');
67435+
// Enumerate commits in range (skip merges)
67436+
const revListResult = await exec.getExecOutput('git', ['rev-list', '--no-merges', commitsRange], { ignoreReturnCode: true, silent: true });
67437+
if (revListResult.exitCode !== 0) {
67438+
throw new Error(`Failed to enumerate commits in range '${commitsRange}': ${revListResult.stderr.trim()}`);
67439+
}
67440+
const commitShas = revListResult.stdout.trim().split('\n').filter(s => s.length > 0);
67441+
if (commitShas.length === 0) {
67442+
core.info('No commits found in range (or all are merge commits)');
6742667443
}
6742767444
else {
67428-
core.info('');
67429-
core.info('=== Post-Sign Verification ===');
67430-
for (const file of signedFiles) {
67431-
const result = await exec.getExecOutput(authsPath, ['artifact', 'verify', file, '--identity-bundle', credentials.verifyBundlePath, '--json'], { ignoreReturnCode: true, silent: true });
67432-
if (result.stdout.trim()) {
67433-
try {
67434-
const parsed = JSON.parse(result.stdout.trim());
67435-
if (parsed.valid === true) {
67436-
core.info(`\u2713 Verified ${path.basename(file)}${parsed.issuer ? ` (issuer: ${parsed.issuer})` : ''}`);
67445+
core.info(`Found ${commitShas.length} commit(s) to sign`);
67446+
for (const sha of commitShas) {
67447+
core.info(`Signing commit: ${sha.substring(0, 8)}`);
67448+
const cliArgs = [
67449+
'signcommit', sha,
67450+
'--device-key', deviceKey,
67451+
'--repo', credentials.identityRepoPath,
67452+
];
67453+
const exitCode = await exec.exec(authsPath, cliArgs, {
67454+
env: authsEnv,
67455+
ignoreReturnCode: true,
67456+
});
67457+
if (exitCode !== 0) {
67458+
core.warning(`Failed to sign commit ${sha.substring(0, 8)} (exit code ${exitCode})`);
67459+
continue;
67460+
}
67461+
signedCommits.push(sha);
67462+
core.info(`\u2713 Signed commit ${sha.substring(0, 8)}`);
67463+
}
67464+
// Push attestation refs
67465+
if (signedCommits.length > 0) {
67466+
core.info('Pushing attestation refs...');
67467+
const pushResult = await exec.exec('git', ['push', 'origin', 'refs/auths/commits/*:refs/auths/commits/*'], { ignoreReturnCode: true });
67468+
if (pushResult !== 0) {
67469+
core.warning('Failed to push attestation refs (may not have contents: write permission)');
67470+
}
67471+
else {
67472+
core.info(`\u2713 Pushed attestation refs for ${signedCommits.length} commit(s)`);
67473+
}
67474+
// Also push KERI refs if they exist
67475+
const keriCheck = await exec.getExecOutput('git', ['show-ref', '--heads', '--tags'], { ignoreReturnCode: true, silent: true });
67476+
if (keriCheck.stdout.includes('refs/keri')) {
67477+
await exec.exec('git', ['push', 'origin', 'refs/keri/*:refs/keri/*'], {
67478+
ignoreReturnCode: true,
67479+
});
67480+
}
67481+
}
67482+
}
67483+
}
67484+
// 5. Optionally verify
67485+
if (shouldVerify) {
67486+
// Verify artifacts
67487+
if (signedFiles.length > 0) {
67488+
if (!credentials.verifyBundlePath) {
67489+
core.warning('verify: true requested but no verify bundle available for artifacts.');
67490+
allVerified = false;
67491+
}
67492+
else {
67493+
core.info('');
67494+
core.info('=== Post-Sign Artifact Verification ===');
67495+
for (const file of signedFiles) {
67496+
const result = await exec.getExecOutput(authsPath, ['artifact', 'verify', file, '--identity-bundle', credentials.verifyBundlePath, '--json'], { ignoreReturnCode: true, silent: true });
67497+
if (result.stdout.trim()) {
67498+
try {
67499+
const parsed = JSON.parse(result.stdout.trim());
67500+
if (parsed.valid === true) {
67501+
core.info(`\u2713 Verified ${path.basename(file)}${parsed.issuer ? ` (issuer: ${parsed.issuer})` : ''}`);
67502+
}
67503+
else {
67504+
core.warning(`\u2717 ${path.basename(file)}: ${parsed.error || 'verification returned valid=false'}`);
67505+
allVerified = false;
67506+
}
6743767507
}
67438-
else {
67439-
core.warning(`\u2717 ${path.basename(file)}: ${parsed.error || 'verification returned valid=false'}`);
67508+
catch {
67509+
core.warning(`\u2717 ${path.basename(file)}: could not parse verification output`);
6744067510
allVerified = false;
6744167511
}
6744267512
}
67443-
catch {
67444-
core.warning(`\u2717 ${path.basename(file)}: could not parse verification output`);
67513+
else {
67514+
core.warning(`\u2717 ${path.basename(file)}: ${result.stderr.trim() || `exit code ${result.exitCode}`}`);
6744567515
allVerified = false;
6744667516
}
6744767517
}
67518+
}
67519+
}
67520+
// Verify commits
67521+
if (signedCommits.length > 0) {
67522+
core.info('');
67523+
core.info('=== Post-Sign Commit Verification ===');
67524+
for (const sha of signedCommits) {
67525+
const verifyArgs = ['verify-commit', sha];
67526+
if (credentials.verifyBundlePath) {
67527+
verifyArgs.push('--identity-bundle', credentials.verifyBundlePath);
67528+
}
67529+
const result = await exec.exec(authsPath, verifyArgs, {
67530+
env: authsEnv,
67531+
ignoreReturnCode: true,
67532+
});
67533+
if (result === 0) {
67534+
core.info(`\u2713 Verified commit ${sha.substring(0, 8)}`);
67535+
}
6744867536
else {
67449-
core.warning(`\u2717 ${path.basename(file)}: ${result.stderr.trim() || `exit code ${result.exitCode}`}`);
67537+
core.warning(`\u2717 Commit ${sha.substring(0, 8)} verification failed`);
6745067538
allVerified = false;
6745167539
}
6745267540
}
@@ -67455,12 +67543,13 @@ async function run() {
6745567543
// 6. Set outputs
6745667544
core.setOutput('signed-files', JSON.stringify(signedFiles));
6745767545
core.setOutput('attestation-files', JSON.stringify(attestationFiles));
67546+
core.setOutput('signed-commits', JSON.stringify(signedCommits));
6745867547
core.setOutput('verified', shouldVerify ? allVerified.toString() : '');
6745967548
// 7. Step summary
67460-
await writeStepSummary(signedFiles, attestationFiles, shouldVerify, allVerified);
67549+
await writeStepSummary(signedFiles, attestationFiles, signedCommits, shouldVerify, allVerified);
6746167550
// 8. Fail if verification was requested and failed
6746267551
if (shouldVerify && !allVerified) {
67463-
core.setFailed('Post-sign verification failed for one or more files');
67552+
core.setFailed('Post-sign verification failed for one or more artifacts/commits');
6746467553
}
6746567554
}
6746667555
catch (error) {
@@ -67477,24 +67566,42 @@ async function run() {
6747767566
}
6747867567
}
6747967568
}
67480-
async function writeStepSummary(signedFiles, attestationFiles, verified, allVerified) {
67481-
if (signedFiles.length === 0)
67569+
async function writeStepSummary(signedFiles, attestationFiles, signedCommits, verified, allVerified) {
67570+
if (signedFiles.length === 0 && signedCommits.length === 0)
6748267571
return;
6748367572
const lines = [];
67484-
lines.push('## Auths Artifact Signing');
67485-
lines.push('');
67486-
lines.push('| Artifact | Attestation | Status |');
67487-
lines.push('|----------|-------------|--------|');
67488-
for (let i = 0; i < signedFiles.length; i++) {
67489-
const file = path.basename(signedFiles[i]);
67490-
const attest = path.basename(attestationFiles[i]);
67491-
const status = verified
67492-
? (allVerified ? '\u2705 Signed + Verified' : '\u26a0\ufe0f Signed (verify failed)')
67493-
: '\u2705 Signed';
67494-
lines.push(`| \`${file}\` | \`${attest}\` | ${status} |`);
67495-
}
67573+
lines.push('## Auths Signing');
6749667574
lines.push('');
67497-
lines.push(`**${signedFiles.length}** artifact(s) signed`);
67575+
if (signedFiles.length > 0) {
67576+
lines.push('### Artifacts');
67577+
lines.push('');
67578+
lines.push('| Artifact | Attestation | Status |');
67579+
lines.push('|----------|-------------|--------|');
67580+
for (let i = 0; i < signedFiles.length; i++) {
67581+
const file = path.basename(signedFiles[i]);
67582+
const attest = path.basename(attestationFiles[i]);
67583+
const status = verified
67584+
? (allVerified ? '\u2705 Signed + Verified' : '\u26a0\ufe0f Signed (verify failed)')
67585+
: '\u2705 Signed';
67586+
lines.push(`| \`${file}\` | \`${attest}\` | ${status} |`);
67587+
}
67588+
lines.push('');
67589+
}
67590+
if (signedCommits.length > 0) {
67591+
lines.push('### Commits');
67592+
lines.push('');
67593+
lines.push('| Commit | Status |');
67594+
lines.push('|--------|--------|');
67595+
for (const sha of signedCommits) {
67596+
const status = verified
67597+
? (allVerified ? '\u2705 Signed + Verified' : '\u26a0\ufe0f Signed')
67598+
: '\u2705 Signed';
67599+
lines.push(`| \`${sha.substring(0, 8)}\` | ${status} |`);
67600+
}
67601+
lines.push('');
67602+
}
67603+
const totalCount = signedFiles.length + signedCommits.length;
67604+
lines.push(`**${totalCount}** item(s) signed`);
6749867605
if (verified) {
6749967606
lines.push(allVerified ? '**Verification:** All passed' : '**Verification:** Failed');
6750067607
}

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/__tests__/main.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,21 @@ describe('Sign action integration', () => {
117117
expect(attestations).toEqual(['/workspace/dist/index.js.auths.json']);
118118
});
119119

120-
it('fails when no files match glob', async () => {
120+
it('warns when no files match glob (files-only mode)', async () => {
121121
mockGlobFiles = [];
122122

123123
await runMain();
124124

125-
expect(mockFailed).toContain('No files matched the provided glob patterns');
125+
// With no commits input, empty glob results in a warning (not a hard failure)
126+
expect(mockWarnings.some(m => m.includes('No files matched'))).toBe(true);
127+
});
128+
129+
it('fails when neither files nor commits provided', async () => {
130+
mockMultilineInputs = { 'files': [] };
131+
132+
await runMain();
133+
134+
expect(mockFailed).toContain('At least one of `files` or `commits` must be provided');
126135
});
127136

128137
it('fails when signing returns non-zero exit code', async () => {
@@ -158,7 +167,7 @@ describe('Sign action integration', () => {
158167
await runMain();
159168

160169
expect(mockOutputs['verified']).toBe('false');
161-
expect(mockFailed).toContain('Post-sign verification failed for one or more files');
170+
expect(mockFailed).toContain('Post-sign verification failed for one or more artifacts/commits');
162171
});
163172

164173
it('warns when verify requested but no bundle', async () => {

0 commit comments

Comments
 (0)