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
63 changes: 63 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,69 @@ jobs:
# attestation files when wrapped commands fail (exit code != 0).
# See CLAUDE.md "Witness CLI Doesn't Create Attestations on Failure" for details.

# Test 5: Subdirectory build WITHOUT workingdir (should fail - simulates customer issue)
- name: Test subdirectory build without workingdir (expect failure)
id: subdir-no-workingdir
continue-on-error: true
uses: ./
with:
step: subdir-no-workingdir
attestations: command-run
enable-archivista: false
enable-sigstore: false
key: ${{ env.KEY_PATH }}
outfile: test-subdir-fail.att
command: make -C . -f Makefile build docker_tag=test123

- name: Verify subdirectory build failed without workingdir
run: |
# The make command should fail because Makefile is not in repo root
if [ "${{ steps.subdir-no-workingdir.outcome }}" == "success" ]; then
# Check if it actually ran from the right place
if [ -f test-subdir-fail.att ]; then
echo "WARNING: Command succeeded but may have run from wrong directory"
fi
else
echo "✅ Expected failure: make couldn't find Makefile in repo root"
fi

# Test 6: Subdirectory build WITH workingdir (should succeed)
- name: Test subdirectory build with workingdir
uses: ./
with:
step: subdir-with-workingdir
attestations: command-run
enable-archivista: false
enable-sigstore: false
key: ${{ env.KEY_PATH }}
outfile: test-subdir-success.att
workingdir: test-fixtures/services/myapp
command: make build docker_tag=test123

- name: Verify subdirectory build succeeded with workingdir
run: |
test -f test-subdir-success.att || { echo "ERROR: Attestation not created"; exit 1; }

# Check the command output shows correct directory
STDOUT=$(jq -r '.payload' test-subdir-success.att | base64 -d | jq -r '.predicate.attestations[] | select(.type | contains("command-run")) | .attestation.stdout')

if echo "$STDOUT" | grep -q "SUCCESS: Dockerfile found"; then
echo "✅ Subdirectory build with workingdir passed"
else
echo "ERROR: Build did not find Dockerfile"
echo "STDOUT: $STDOUT"
exit 1
fi

# Verify docker_tag was passed correctly
if echo "$STDOUT" | grep -q "docker_tag=test123"; then
echo "✅ Variable passing works correctly"
else
echo "ERROR: docker_tag not passed correctly"
echo "STDOUT: $STDOUT"
exit 1
fi

- name: Upload test attestations
uses: actions/upload-artifact@v4
if: always()
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ A GitHub Action that wraps commands with [Witness](https://github.com/in-toto/wi

## Quick Start

### Wrap a command

```yaml
jobs:
build:
Expand Down
161 changes: 161 additions & 0 deletions __tests__/commandFlow.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* Test to verify command flow through the wrapper
* Specifically testing that variables and complex commands are preserved
*/

const assembleWitnessArgs = require('../src/attestation/assembleWitnessArgs');

describe('Command Flow Tests', () => {
describe('assembleWitnessArgs with shell commands', () => {
const baseOptions = {
step: 'build',
outfile: 'attestation.json',
enableArchivista: false,
attestations: ['git', 'github']
};

test('preserves variable-like strings in command', () => {
// Simulating what GitHub Actions would pass after substitution
// ${{ steps.load-image.outputs.IMAGE_TAG }} becomes the actual value
const command = 'make build docker_tag=v1.2.3-abc123';
const commandArray = ['/bin/sh', '-c', command];

const args = assembleWitnessArgs(baseOptions, commandArray);

// Find the command in the args (after --)
const dashDashIndex = args.indexOf('--');
expect(dashDashIndex).toBeGreaterThan(-1);

const shellArgs = args.slice(dashDashIndex + 1);
expect(shellArgs).toEqual(['/bin/sh', '-c', 'make build docker_tag=v1.2.3-abc123']);
});

test('preserves command with equals sign and special chars', () => {
const command = 'make build docker_tag=my-image:v1.0.0-sha.abc123';
const commandArray = ['/bin/sh', '-c', command];

const args = assembleWitnessArgs(baseOptions, commandArray);

const dashDashIndex = args.indexOf('--');
const shellArgs = args.slice(dashDashIndex + 1);

// The exact command should be preserved
expect(shellArgs[2]).toBe('make build docker_tag=my-image:v1.0.0-sha.abc123');
});

test('preserves command with spaces in quoted values', () => {
const command = 'echo "hello world" && make build TAG="my tag with spaces"';
const commandArray = ['/bin/sh', '-c', command];

const args = assembleWitnessArgs(baseOptions, commandArray);

const dashDashIndex = args.indexOf('--');
const shellArgs = args.slice(dashDashIndex + 1);

expect(shellArgs[2]).toBe('echo "hello world" && make build TAG="my tag with spaces"');
});

test('preserves multi-line commands', () => {
const command = `cd /tmp
echo "line 1"
echo "line 2"`;
const commandArray = ['/bin/sh', '-c', command];

const args = assembleWitnessArgs(baseOptions, commandArray);

const dashDashIndex = args.indexOf('--');
const shellArgs = args.slice(dashDashIndex + 1);

// Multi-line should be preserved as single string
expect(shellArgs[2]).toContain('cd /tmp');
expect(shellArgs[2]).toContain('echo "line 1"');
expect(shellArgs[2]).toContain('echo "line 2"');
});

test('preserves pipes and redirects', () => {
const command = 'ls -la | grep foo > output.txt 2>&1';
const commandArray = ['/bin/sh', '-c', command];

const args = assembleWitnessArgs(baseOptions, commandArray);

const dashDashIndex = args.indexOf('--');
const shellArgs = args.slice(dashDashIndex + 1);

expect(shellArgs[2]).toBe('ls -la | grep foo > output.txt 2>&1');
});

test('preserves environment variable syntax (for runtime expansion)', () => {
// Note: $VAR syntax should be preserved for shell to expand at runtime
const command = 'echo $HOME && make build TAG=$MY_TAG';
const commandArray = ['/bin/sh', '-c', command];

const args = assembleWitnessArgs(baseOptions, commandArray);

const dashDashIndex = args.indexOf('--');
const shellArgs = args.slice(dashDashIndex + 1);

expect(shellArgs[2]).toBe('echo $HOME && make build TAG=$MY_TAG');
});

test('full args structure is correct', () => {
const command = 'make build docker_tag=v1.0.0';
const commandArray = ['/bin/sh', '-c', command];

const args = assembleWitnessArgs(baseOptions, commandArray);

// Should start with 'run'
expect(args[0]).toBe('run');

// Should have step and outfile
expect(args).toContain('-s=build');
expect(args).toContain('-o=attestation.json');

// Should have -- separator
expect(args).toContain('--');

// Command should come after --
const dashDashIndex = args.indexOf('--');
expect(args[dashDashIndex + 1]).toBe('/bin/sh');
expect(args[dashDashIndex + 2]).toBe('-c');
expect(args[dashDashIndex + 3]).toBe('make build docker_tag=v1.0.0');
});
});

describe('Edge cases', () => {
const baseOptions = {
step: 'test',
enableArchivista: false
};

test('handles empty command gracefully', () => {
const commandArray = ['/bin/sh', '-c', ''];
const args = assembleWitnessArgs(baseOptions, commandArray);

const dashDashIndex = args.indexOf('--');
const shellArgs = args.slice(dashDashIndex + 1);

expect(shellArgs[2]).toBe('');
});

test('handles command with only whitespace', () => {
const commandArray = ['/bin/sh', '-c', ' '];
const args = assembleWitnessArgs(baseOptions, commandArray);

const dashDashIndex = args.indexOf('--');
const shellArgs = args.slice(dashDashIndex + 1);

expect(shellArgs[2]).toBe(' ');
});

test('handles null in extraArgs', () => {
const commandArray = ['/bin/sh', '-c', null];
const args = assembleWitnessArgs(baseOptions, commandArray);

const dashDashIndex = args.indexOf('--');
const shellArgs = args.slice(dashDashIndex + 1);

// null should be converted to empty string
expect(shellArgs[2]).toBe('');
});
});
});
118 changes: 118 additions & 0 deletions __tests__/inputFlow.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* Test to verify how GitHub Actions inputs flow through the wrapper
*
* Key insight: GitHub Actions evaluates ${{ }} expressions BEFORE passing to the action.
* So INPUT_COMMAND already contains the substituted value.
*/

describe('GitHub Actions Input Flow', () => {
const originalEnv = process.env;

beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});

describe('core.getInput behavior', () => {
test('reads INPUT_COMMAND with substituted variable value', () => {
// Simulate what GitHub Actions does:
// command: make build docker_tag=${{ steps.load-image.outputs.IMAGE_TAG }}
// becomes:
// INPUT_COMMAND=make build docker_tag=v1.2.3-abc123
process.env.INPUT_COMMAND = 'make build docker_tag=v1.2.3-abc123';

const core = require('@actions/core');
const command = core.getInput('command');

expect(command).toBe('make build docker_tag=v1.2.3-abc123');
});

test('reads INPUT_COMMAND with empty variable value', () => {
// If IMAGE_TAG is empty, GitHub passes empty string
process.env.INPUT_COMMAND = 'make build docker_tag=';

const core = require('@actions/core');
const command = core.getInput('command');

expect(command).toBe('make build docker_tag=');
});

test('reads INPUT_COMMAND with complex docker tag', () => {
// Common pattern: registry/image:tag-sha
process.env.INPUT_COMMAND = 'make build docker_tag=ghcr.io/myorg/myimage:v1.0.0-sha.abc1234';

const core = require('@actions/core');
const command = core.getInput('command');

expect(command).toBe('make build docker_tag=ghcr.io/myorg/myimage:v1.0.0-sha.abc1234');
});

test('preserves multi-line command from GitHub Actions', () => {
// GitHub Actions preserves newlines in multi-line YAML
process.env.INPUT_COMMAND = `echo "Starting build"
make build docker_tag=v1.0.0
echo "Build complete"`;

const core = require('@actions/core');
const command = core.getInput('command');

expect(command).toContain('echo "Starting build"');
expect(command).toContain('make build docker_tag=v1.0.0');
expect(command).toContain('echo "Build complete"');
});
});

describe('Full command flow simulation', () => {
test('command flows correctly to witness args', () => {
// Set up the environment as GitHub Actions would
process.env.INPUT_COMMAND = 'make build docker_tag=v1.2.3';
process.env.INPUT_STEP = 'build';
process.env['INPUT_ENABLE-ARCHIVISTA'] = 'false';
process.env.INPUT_OUTFILE = 'attestation.json';

const core = require('@actions/core');
const assembleWitnessArgs = require('../src/attestation/assembleWitnessArgs');

// This is what runDirectCommandWithWitness does
const command = core.getInput('command');
const commandArray = ['/bin/sh', '-c', command];

const witnessOptions = {
step: core.getInput('step'),
enableArchivista: false,
outfile: core.getInput('outfile')
};

const args = assembleWitnessArgs(witnessOptions, commandArray);

// Verify the final command structure
const dashDashIndex = args.indexOf('--');
expect(args.slice(dashDashIndex)).toEqual([
'--',
'/bin/sh',
'-c',
'make build docker_tag=v1.2.3'
]);
});

test('handles special characters in docker tag', () => {
// Docker tags can have: alphanumerics, periods, hyphens, underscores
process.env.INPUT_COMMAND = 'make build docker_tag=my-app_v1.0.0-beta.1';

const core = require('@actions/core');
const assembleWitnessArgs = require('../src/attestation/assembleWitnessArgs');

const command = core.getInput('command');
const commandArray = ['/bin/sh', '-c', command];

const args = assembleWitnessArgs({ step: 'build' }, commandArray);

const dashDashIndex = args.indexOf('--');
expect(args[dashDashIndex + 3]).toBe('make build docker_tag=my-app_v1.0.0-beta.1');
});
});
});
20 changes: 19 additions & 1 deletion dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36416,7 +36416,25 @@ class ActionWrapperRunner {

// Build witness options from inputs
this.witnessOptions = getWitnessOptions();


// Check if we're being called from a subdirectory without explicit workingdir
const initialCwd = process.cwd();
const workspaceDir = process.env.GITHUB_WORKSPACE;
const explicitWorkingDir = core.getInput('workingdir');

if (workspaceDir && initialCwd !== workspaceDir && !explicitWorkingDir) {
const relativePath = initialCwd.replace(workspaceDir, '').replace(/^\//, '');
core.warning(
`Working directory mismatch detected!\n` +
` Current directory: ${initialCwd}\n` +
` GITHUB_WORKSPACE: ${workspaceDir}\n` +
` Witness will run from GITHUB_WORKSPACE by default.\n` +
` If your Dockerfile/Makefile is in a subdirectory, add:\n` +
` workingdir: ${relativePath || '.'}\n` +
` to your witness-wrapper step inputs.`
);
}

// Ensure we run in the GitHub workspace
process.chdir(process.env.GITHUB_WORKSPACE || process.cwd());
core.info(`Running in directory ${process.cwd()}`);
Expand Down
Loading
Loading