Skip to content
Merged
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
15 changes: 11 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: 'Sign Artifacts with Auths'
description: 'Sign build artifacts using Auths identity keys in CI'
name: 'Sign with Auths'
description: 'Sign build artifacts and/or commits using Auths identity keys in CI'
author: 'auths'

inputs:
Expand All @@ -25,7 +25,12 @@ inputs:
default: ''
files:
description: 'Glob patterns for files to sign, one per line'
required: true
required: false
default: ''
commits:
description: 'Git revision range to sign (e.g., HEAD~1..HEAD). Merge commits are skipped.'
required: false
default: ''
verify:
description: 'Verify each file after signing using the verify bundle from the token'
required: false
Expand All @@ -48,8 +53,10 @@ outputs:
description: 'JSON array of file paths that were signed'
attestation-files:
description: 'JSON array of attestation file paths (.auths.json)'
signed-commits:
description: 'JSON array of signed commit SHAs'
verified:
description: 'Whether all signed files passed verification (empty string if verify was not requested)'
description: 'Whether all signed artifacts/commits passed verification (empty string if verify was not requested)'

runs:
using: 'node20'
Expand Down
275 changes: 191 additions & 84 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -67353,6 +67353,12 @@ const token_1 = __nccwpck_require__(94606);
async function run() {
let credentials = null;
try {
// Validate: at least one of files or commits must be provided
const filePatterns = core.getMultilineInput('files').filter(p => p.trim());
const commitsRange = core.getInput('commits').trim();
if (filePatterns.length === 0 && !commitsRange) {
throw new Error('At least one of `files` or `commits` must be provided');
}
// 1. Resolve credentials
credentials = await (0, token_1.resolveCredentials)();
core.info('Credentials resolved');
Expand All @@ -67362,91 +67368,173 @@ async function run() {
if (!authsPath) {
throw new Error('Failed to find or install auths CLI');
}
// 3. Glob match files
const filePatterns = core.getMultilineInput('files', { required: true });
const patterns = filePatterns.join('\n');
const globber = await glob.create(patterns, { followSymbolicLinks: false });
let files = await globber.glob();
// Workspace containment check
const workspace = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd());
files = files.filter(f => {
const resolved = path.resolve(f);
if (!resolved.startsWith(workspace + path.sep) && resolved !== workspace) {
core.warning(`Skipping path outside workspace: ${f}`);
return false;
}
return true;
});
files = [...new Set(files)];
if (files.length === 0) {
throw new Error('No files matched the provided glob patterns');
}
core.info(`Found ${files.length} file(s) to sign`);
// 4. Sign each file
const deviceKey = core.getInput('device-key') || 'ci-release-device';
const note = core.getInput('note') || '';
const shouldVerify = core.getInput('verify') === 'true';
let allVerified = true;
const signedFiles = [];
const attestationFiles = [];
for (const file of files) {
core.info(`Signing: ${path.basename(file)}`);
const cliArgs = [
'artifact', 'sign', file,
'--device-key', deviceKey,
'--repo', credentials.identityRepoPath,
];
if (note)
cliArgs.push('--note', note);
const exitCode = await exec.exec(authsPath, cliArgs, {
env: {
...process.env,
AUTHS_PASSPHRASE: credentials.passphrase,
AUTHS_KEYCHAIN_BACKEND: 'file',
AUTHS_KEYCHAIN_FILE: credentials.keychainPath,
},
ignoreReturnCode: true,
const signedCommits = [];
const authsEnv = {
...process.env,
AUTHS_PASSPHRASE: credentials.passphrase,
AUTHS_KEYCHAIN_BACKEND: 'file',
AUTHS_KEYCHAIN_FILE: credentials.keychainPath,
};
// 3. Sign artifacts (if files provided)
if (filePatterns.length > 0) {
const patterns = filePatterns.join('\n');
const globber = await glob.create(patterns, { followSymbolicLinks: false });
let files = await globber.glob();
// Workspace containment check
const workspace = path.resolve(process.env.GITHUB_WORKSPACE || process.cwd());
files = files.filter(f => {
const resolved = path.resolve(f);
if (!resolved.startsWith(workspace + path.sep) && resolved !== workspace) {
core.warning(`Skipping path outside workspace: ${f}`);
return false;
}
return true;
});
if (exitCode !== 0) {
throw new Error(`Failed to sign ${path.basename(file)} (exit code ${exitCode})`);
files = [...new Set(files)];
if (files.length === 0) {
core.warning('No files matched the provided glob patterns');
}
const attestationPath = `${file}.auths.json`;
if (!fs.existsSync(attestationPath)) {
throw new Error(`Signing succeeded but attestation file not found: ${attestationPath}`);
else {
core.info(`Found ${files.length} file(s) to sign`);
for (const file of files) {
core.info(`Signing: ${path.basename(file)}`);
const cliArgs = [
'artifact', 'sign', file,
'--device-key', deviceKey,
'--repo', credentials.identityRepoPath,
];
if (note)
cliArgs.push('--note', note);
const exitCode = await exec.exec(authsPath, cliArgs, {
env: authsEnv,
ignoreReturnCode: true,
});
if (exitCode !== 0) {
throw new Error(`Failed to sign ${path.basename(file)} (exit code ${exitCode})`);
}
const attestationPath = `${file}.auths.json`;
if (!fs.existsSync(attestationPath)) {
throw new Error(`Signing succeeded but attestation file not found: ${attestationPath}`);
}
signedFiles.push(file);
attestationFiles.push(attestationPath);
core.info(`\u2713 ${path.basename(file)} -> ${path.basename(attestationPath)}`);
}
}
signedFiles.push(file);
attestationFiles.push(attestationPath);
core.info(`\u2713 ${path.basename(file)} -> ${path.basename(attestationPath)}`);
}
// 5. Optionally verify
const shouldVerify = core.getInput('verify') === 'true';
let allVerified = true;
if (shouldVerify) {
if (!credentials.verifyBundlePath) {
core.warning('verify: true requested but no verify bundle available. Provide verify_bundle in the CiToken or set the verify-bundle input.');
allVerified = false;
// 4. Sign commits (if commits range provided)
if (commitsRange) {
core.info('');
core.info('=== Commit Signing ===');
// Enumerate commits in range (skip merges)
const revListResult = await exec.getExecOutput('git', ['rev-list', '--no-merges', commitsRange], { ignoreReturnCode: true, silent: true });
if (revListResult.exitCode !== 0) {
throw new Error(`Failed to enumerate commits in range '${commitsRange}': ${revListResult.stderr.trim()}`);
}
const commitShas = revListResult.stdout.trim().split('\n').filter(s => s.length > 0);
if (commitShas.length === 0) {
core.info('No commits found in range (or all are merge commits)');
}
else {
core.info('');
core.info('=== Post-Sign Verification ===');
for (const file of signedFiles) {
const result = await exec.getExecOutput(authsPath, ['artifact', 'verify', file, '--identity-bundle', credentials.verifyBundlePath, '--json'], { ignoreReturnCode: true, silent: true });
if (result.stdout.trim()) {
try {
const parsed = JSON.parse(result.stdout.trim());
if (parsed.valid === true) {
core.info(`\u2713 Verified ${path.basename(file)}${parsed.issuer ? ` (issuer: ${parsed.issuer})` : ''}`);
core.info(`Found ${commitShas.length} commit(s) to sign`);
for (const sha of commitShas) {
core.info(`Signing commit: ${sha.substring(0, 8)}`);
const cliArgs = [
'signcommit', sha,
'--device-key', deviceKey,
'--repo', credentials.identityRepoPath,
];
const exitCode = await exec.exec(authsPath, cliArgs, {
env: authsEnv,
ignoreReturnCode: true,
});
if (exitCode !== 0) {
core.warning(`Failed to sign commit ${sha.substring(0, 8)} (exit code ${exitCode})`);
continue;
}
signedCommits.push(sha);
core.info(`\u2713 Signed commit ${sha.substring(0, 8)}`);
}
// Push attestation refs
if (signedCommits.length > 0) {
core.info('Pushing attestation refs...');
const pushResult = await exec.exec('git', ['push', 'origin', 'refs/auths/commits/*:refs/auths/commits/*'], { ignoreReturnCode: true });
if (pushResult !== 0) {
core.warning('Failed to push attestation refs (may not have contents: write permission)');
}
else {
core.info(`\u2713 Pushed attestation refs for ${signedCommits.length} commit(s)`);
}
// Also push KERI refs if they exist
const keriCheck = await exec.getExecOutput('git', ['show-ref', '--heads', '--tags'], { ignoreReturnCode: true, silent: true });
if (keriCheck.stdout.includes('refs/keri')) {
await exec.exec('git', ['push', 'origin', 'refs/keri/*:refs/keri/*'], {
ignoreReturnCode: true,
});
}
}
}
}
// 5. Optionally verify
if (shouldVerify) {
// Verify artifacts
if (signedFiles.length > 0) {
if (!credentials.verifyBundlePath) {
core.warning('verify: true requested but no verify bundle available for artifacts.');
allVerified = false;
}
else {
core.info('');
core.info('=== Post-Sign Artifact Verification ===');
for (const file of signedFiles) {
const result = await exec.getExecOutput(authsPath, ['artifact', 'verify', file, '--identity-bundle', credentials.verifyBundlePath, '--json'], { ignoreReturnCode: true, silent: true });
if (result.stdout.trim()) {
try {
const parsed = JSON.parse(result.stdout.trim());
if (parsed.valid === true) {
core.info(`\u2713 Verified ${path.basename(file)}${parsed.issuer ? ` (issuer: ${parsed.issuer})` : ''}`);
}
else {
core.warning(`\u2717 ${path.basename(file)}: ${parsed.error || 'verification returned valid=false'}`);
allVerified = false;
}
}
else {
core.warning(`\u2717 ${path.basename(file)}: ${parsed.error || 'verification returned valid=false'}`);
catch {
core.warning(`\u2717 ${path.basename(file)}: could not parse verification output`);
allVerified = false;
}
}
catch {
core.warning(`\u2717 ${path.basename(file)}: could not parse verification output`);
else {
core.warning(`\u2717 ${path.basename(file)}: ${result.stderr.trim() || `exit code ${result.exitCode}`}`);
allVerified = false;
}
}
}
}
// Verify commits
if (signedCommits.length > 0) {
core.info('');
core.info('=== Post-Sign Commit Verification ===');
for (const sha of signedCommits) {
const verifyArgs = ['verify-commit', sha];
if (credentials.verifyBundlePath) {
verifyArgs.push('--identity-bundle', credentials.verifyBundlePath);
}
const result = await exec.exec(authsPath, verifyArgs, {
env: authsEnv,
ignoreReturnCode: true,
});
if (result === 0) {
core.info(`\u2713 Verified commit ${sha.substring(0, 8)}`);
}
else {
core.warning(`\u2717 ${path.basename(file)}: ${result.stderr.trim() || `exit code ${result.exitCode}`}`);
core.warning(`\u2717 Commit ${sha.substring(0, 8)} verification failed`);
allVerified = false;
}
}
Expand All @@ -67455,12 +67543,13 @@ async function run() {
// 6. Set outputs
core.setOutput('signed-files', JSON.stringify(signedFiles));
core.setOutput('attestation-files', JSON.stringify(attestationFiles));
core.setOutput('signed-commits', JSON.stringify(signedCommits));
core.setOutput('verified', shouldVerify ? allVerified.toString() : '');
// 7. Step summary
await writeStepSummary(signedFiles, attestationFiles, shouldVerify, allVerified);
await writeStepSummary(signedFiles, attestationFiles, signedCommits, shouldVerify, allVerified);
// 8. Fail if verification was requested and failed
if (shouldVerify && !allVerified) {
core.setFailed('Post-sign verification failed for one or more files');
core.setFailed('Post-sign verification failed for one or more artifacts/commits');
}
}
catch (error) {
Expand All @@ -67477,24 +67566,42 @@ async function run() {
}
}
}
async function writeStepSummary(signedFiles, attestationFiles, verified, allVerified) {
if (signedFiles.length === 0)
async function writeStepSummary(signedFiles, attestationFiles, signedCommits, verified, allVerified) {
if (signedFiles.length === 0 && signedCommits.length === 0)
return;
const lines = [];
lines.push('## Auths Artifact Signing');
lines.push('');
lines.push('| Artifact | Attestation | Status |');
lines.push('|----------|-------------|--------|');
for (let i = 0; i < signedFiles.length; i++) {
const file = path.basename(signedFiles[i]);
const attest = path.basename(attestationFiles[i]);
const status = verified
? (allVerified ? '\u2705 Signed + Verified' : '\u26a0\ufe0f Signed (verify failed)')
: '\u2705 Signed';
lines.push(`| \`${file}\` | \`${attest}\` | ${status} |`);
}
lines.push('## Auths Signing');
lines.push('');
lines.push(`**${signedFiles.length}** artifact(s) signed`);
if (signedFiles.length > 0) {
lines.push('### Artifacts');
lines.push('');
lines.push('| Artifact | Attestation | Status |');
lines.push('|----------|-------------|--------|');
for (let i = 0; i < signedFiles.length; i++) {
const file = path.basename(signedFiles[i]);
const attest = path.basename(attestationFiles[i]);
const status = verified
? (allVerified ? '\u2705 Signed + Verified' : '\u26a0\ufe0f Signed (verify failed)')
: '\u2705 Signed';
lines.push(`| \`${file}\` | \`${attest}\` | ${status} |`);
}
lines.push('');
}
if (signedCommits.length > 0) {
lines.push('### Commits');
lines.push('');
lines.push('| Commit | Status |');
lines.push('|--------|--------|');
for (const sha of signedCommits) {
const status = verified
? (allVerified ? '\u2705 Signed + Verified' : '\u26a0\ufe0f Signed')
: '\u2705 Signed';
lines.push(`| \`${sha.substring(0, 8)}\` | ${status} |`);
}
lines.push('');
}
const totalCount = signedFiles.length + signedCommits.length;
lines.push(`**${totalCount}** item(s) signed`);
if (verified) {
lines.push(allVerified ? '**Verification:** All passed' : '**Verification:** Failed');
}
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

15 changes: 12 additions & 3 deletions src/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,21 @@ describe('Sign action integration', () => {
expect(attestations).toEqual(['/workspace/dist/index.js.auths.json']);
});

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

await runMain();

expect(mockFailed).toContain('No files matched the provided glob patterns');
// With no commits input, empty glob results in a warning (not a hard failure)
expect(mockWarnings.some(m => m.includes('No files matched'))).toBe(true);
});

it('fails when neither files nor commits provided', async () => {
mockMultilineInputs = { 'files': [] };

await runMain();

expect(mockFailed).toContain('At least one of `files` or `commits` must be provided');
});

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

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

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