diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f4a8e4..d3e457c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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() diff --git a/README.md b/README.md index 3acdf0a..ba23b77 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/__tests__/commandFlow.test.js b/__tests__/commandFlow.test.js new file mode 100644 index 0000000..2698b6d --- /dev/null +++ b/__tests__/commandFlow.test.js @@ -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(''); + }); + }); +}); diff --git a/__tests__/inputFlow.test.js b/__tests__/inputFlow.test.js new file mode 100644 index 0000000..ebb2f9f --- /dev/null +++ b/__tests__/inputFlow.test.js @@ -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'); + }); + }); +}); diff --git a/dist/index.js b/dist/index.js index 3416386..989c297 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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()}`); diff --git a/src/runners/ActionWrapperRunner.js b/src/runners/ActionWrapperRunner.js index 60d33a9..29c2de1 100644 --- a/src/runners/ActionWrapperRunner.js +++ b/src/runners/ActionWrapperRunner.js @@ -38,7 +38,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()}`); diff --git a/test-fixtures/services/myapp/Dockerfile b/test-fixtures/services/myapp/Dockerfile new file mode 100644 index 0000000..90c01c7 --- /dev/null +++ b/test-fixtures/services/myapp/Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:latest +RUN echo "Built from subdirectory" diff --git a/test-fixtures/services/myapp/Makefile b/test-fixtures/services/myapp/Makefile new file mode 100644 index 0000000..833c546 --- /dev/null +++ b/test-fixtures/services/myapp/Makefile @@ -0,0 +1,15 @@ +# Test Makefile for subdirectory build test +docker_tag ?= test + +.PHONY: build check-dir + +build: + @echo "Building from directory: $$(pwd)" + @echo "docker_tag=$(docker_tag)" + @test -f Dockerfile || (echo "ERROR: Dockerfile not found in $$(pwd)" && exit 1) + @echo "SUCCESS: Dockerfile found, build would succeed" + +check-dir: + @echo "Current directory: $$(pwd)" + @echo "Makefile location: $$(dirname $(MAKEFILE_LIST))" + @ls -la