@stream44.studio/dco 0.3.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dco-signatures +9 -0
- package/.github/workflows/dco.yml +12 -0
- package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
- package/.o/GordianOpenIntegrity.yaml +25 -0
- package/DCO.md +34 -0
- package/README.md +122 -0
- package/action.yml +32 -0
- package/caps/Dco.test.ts +288 -0
- package/caps/Dco.ts +269 -0
- package/commit.sh +468 -0
- package/dco.sh +49 -0
- package/examples/01-Lifecycle/main.test.ts +223 -0
- package/package.json +39 -0
- package/test.sh +422 -0
- package/tsconfig.json +28 -0
- package/validate.sh +353 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env bun test
|
|
2
|
+
|
|
3
|
+
import * as bunTest from 'bun:test'
|
|
4
|
+
import { run } from 't44/workspace-rt'
|
|
5
|
+
import { join, dirname } from 'path'
|
|
6
|
+
import { rm, mkdir, writeFile, readFile, copyFile } from 'fs/promises'
|
|
7
|
+
|
|
8
|
+
const WORK_DIR = join(import.meta.dir, '.~dco-lifecycle')
|
|
9
|
+
const DCO_SH = join(import.meta.dir, '../../dco.sh')
|
|
10
|
+
const DCO_MD_SOURCE = join(import.meta.dir, '../../DCO.md')
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
test: { describe, it, expect },
|
|
14
|
+
} = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
|
|
15
|
+
const spine = await encapsulate({
|
|
16
|
+
'#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
|
|
17
|
+
'#@stream44.studio/encapsulate/structs/Capsule': {},
|
|
18
|
+
'#': {
|
|
19
|
+
test: {
|
|
20
|
+
type: CapsulePropertyTypes.Mapping,
|
|
21
|
+
value: 't44/caps/WorkspaceTest',
|
|
22
|
+
options: {
|
|
23
|
+
'#': {
|
|
24
|
+
bunTest,
|
|
25
|
+
env: {}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}, {
|
|
32
|
+
importMeta: import.meta,
|
|
33
|
+
importStack: makeImportStack(),
|
|
34
|
+
capsuleName: '@stream44.studio/dco/examples/01-Lifecycle'
|
|
35
|
+
})
|
|
36
|
+
return { spine }
|
|
37
|
+
}, async ({ spine, apis }: any) => {
|
|
38
|
+
return apis[spine.capsuleSourceLineRef]
|
|
39
|
+
}, {
|
|
40
|
+
importMeta: import.meta
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
44
|
+
//
|
|
45
|
+
// DCO CLI Lifecycle Test
|
|
46
|
+
//
|
|
47
|
+
// Exercises the full `dco.sh commit` and `dco.sh validate` CLI flow:
|
|
48
|
+
// 1. Author A initialises a repo, signs DCO with a signing key, commits
|
|
49
|
+
// 2. Author B signs DCO with a different key and identity, commits
|
|
50
|
+
// 3. Validate all commits pass DCO validation
|
|
51
|
+
//
|
|
52
|
+
// ════════════════════════════════════════════════════════════════════════
|
|
53
|
+
|
|
54
|
+
async function spawn(args: string[], opts: { cwd: string; env?: Record<string, string> }) {
|
|
55
|
+
const proc = Bun.spawn(args, {
|
|
56
|
+
cwd: opts.cwd,
|
|
57
|
+
stdout: 'pipe',
|
|
58
|
+
stderr: 'pipe',
|
|
59
|
+
env: { ...process.env, ...opts.env },
|
|
60
|
+
})
|
|
61
|
+
const stdout = await new Response(proc.stdout).text()
|
|
62
|
+
const stderr = await new Response(proc.stderr).text()
|
|
63
|
+
const exitCode = await proc.exited
|
|
64
|
+
return { stdout, stderr, exitCode, output: stdout + stderr }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('DCO CLI Lifecycle', function () {
|
|
68
|
+
|
|
69
|
+
const repoDir = join(WORK_DIR, 'repo')
|
|
70
|
+
const keysDir = join(WORK_DIR, 'keys')
|
|
71
|
+
|
|
72
|
+
let keyA: string
|
|
73
|
+
let keyB: string
|
|
74
|
+
|
|
75
|
+
// ──────────────────────────────────────────────────────────────
|
|
76
|
+
// Setup: clean work dir, generate two SSH keys
|
|
77
|
+
// ──────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
it('setup: prepare work directory and SSH keys', async function () {
|
|
80
|
+
await rm(WORK_DIR, { recursive: true, force: true })
|
|
81
|
+
await mkdir(repoDir, { recursive: true })
|
|
82
|
+
await mkdir(keysDir, { recursive: true })
|
|
83
|
+
|
|
84
|
+
// Generate key for Author A
|
|
85
|
+
keyA = join(keysDir, 'author_a_ed25519')
|
|
86
|
+
await spawn(['ssh-keygen', '-t', 'ed25519', '-f', keyA, '-N', '', '-C', 'author_a'], { cwd: keysDir })
|
|
87
|
+
|
|
88
|
+
// Generate key for Author B
|
|
89
|
+
keyB = join(keysDir, 'author_b_ed25519')
|
|
90
|
+
await spawn(['ssh-keygen', '-t', 'ed25519', '-f', keyB, '-N', '', '-C', 'author_b'], { cwd: keysDir })
|
|
91
|
+
|
|
92
|
+
// Init git repo as Author A
|
|
93
|
+
await spawn(['git', 'init'], { cwd: repoDir })
|
|
94
|
+
await spawn(['git', 'config', 'user.name', 'Author A'], { cwd: repoDir })
|
|
95
|
+
await spawn(['git', 'config', 'user.email', 'a@example.com'], { cwd: repoDir })
|
|
96
|
+
await spawn(['git', 'checkout', '-b', 'main'], { cwd: repoDir })
|
|
97
|
+
|
|
98
|
+
// Copy DCO.md into the repo
|
|
99
|
+
await copyFile(DCO_MD_SOURCE, join(repoDir, 'DCO.md'))
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// ──────────────────────────────────────────────────────────────
|
|
103
|
+
// 1. Author A: `dco.sh commit` with signing key
|
|
104
|
+
// ──────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
it('Author A: dco.sh commit --signing-key --yes-signoff', async function () {
|
|
107
|
+
const result = await spawn(
|
|
108
|
+
['bash', DCO_SH, 'commit', '--signing-key', keyA, '--yes-signoff'],
|
|
109
|
+
{ cwd: repoDir }
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if (result.exitCode !== 0) {
|
|
113
|
+
console.error('sign output:', result.output)
|
|
114
|
+
}
|
|
115
|
+
expect(result.exitCode).toBe(0)
|
|
116
|
+
|
|
117
|
+
// Verify .dco-signatures was created and contains Author A
|
|
118
|
+
const sigContent = await readFile(join(repoDir, '.dco-signatures'), 'utf-8')
|
|
119
|
+
expect(sigContent).toContain('Author A')
|
|
120
|
+
expect(sigContent).toContain('a@example.com')
|
|
121
|
+
expect(sigContent).toContain('signature: SHA256:')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// ──────────────────────────────────────────────────────────────
|
|
125
|
+
// 2. Author A: commit some code with --signoff
|
|
126
|
+
// ──────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
it('Author A: commit code with --signoff', async function () {
|
|
129
|
+
await writeFile(join(repoDir, 'README.md'), '# Test Project\n')
|
|
130
|
+
await spawn(['git', 'add', '-A'], { cwd: repoDir })
|
|
131
|
+
|
|
132
|
+
const result = await spawn(
|
|
133
|
+
['git', '-c', 'gpg.format=ssh', '-c', `user.signingkey=${keyA}`, 'commit', '--gpg-sign', '--signoff', '-m', 'feat: initial code by Author A'],
|
|
134
|
+
{ cwd: repoDir }
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if (result.exitCode !== 0) {
|
|
138
|
+
console.error('commit output:', result.output)
|
|
139
|
+
}
|
|
140
|
+
expect(result.exitCode).toBe(0)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// ──────────────────────────────────────────────────────────────
|
|
144
|
+
// 3. Author B: switch identity, sign DCO, commit
|
|
145
|
+
// ──────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
it('Author B: switch identity and dco.sh commit', async function () {
|
|
148
|
+
// Switch git identity to Author B
|
|
149
|
+
await spawn(['git', 'config', 'user.name', 'Author B'], { cwd: repoDir })
|
|
150
|
+
await spawn(['git', 'config', 'user.email', 'b@example.com'], { cwd: repoDir })
|
|
151
|
+
|
|
152
|
+
// Remove the marker so Author B gets prompted
|
|
153
|
+
const markerPath = join(repoDir, '.git', '.dco-agreed')
|
|
154
|
+
await rm(markerPath, { force: true })
|
|
155
|
+
|
|
156
|
+
const result = await spawn(
|
|
157
|
+
['bash', DCO_SH, 'commit', '--signing-key', keyB, '--yes-signoff'],
|
|
158
|
+
{ cwd: repoDir }
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if (result.exitCode !== 0) {
|
|
162
|
+
console.error('sign B output:', result.output)
|
|
163
|
+
}
|
|
164
|
+
expect(result.exitCode).toBe(0)
|
|
165
|
+
|
|
166
|
+
// Verify .dco-signatures now contains both authors
|
|
167
|
+
const sigContent = await readFile(join(repoDir, '.dco-signatures'), 'utf-8')
|
|
168
|
+
expect(sigContent).toContain('Author A')
|
|
169
|
+
expect(sigContent).toContain('Author B')
|
|
170
|
+
expect(sigContent).toContain('b@example.com')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('Author B: commit code with --signoff', async function () {
|
|
174
|
+
await writeFile(join(repoDir, 'CONTRIBUTING.md'), '# Contributing\n')
|
|
175
|
+
await spawn(['git', 'add', '-A'], { cwd: repoDir })
|
|
176
|
+
|
|
177
|
+
const result = await spawn(
|
|
178
|
+
['git', '-c', 'gpg.format=ssh', '-c', `user.signingkey=${keyB}`, 'commit', '--gpg-sign', '--signoff', '-m', 'docs: contributing guide by Author B'],
|
|
179
|
+
{ cwd: repoDir }
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if (result.exitCode !== 0) {
|
|
183
|
+
console.error('commit B output:', result.output)
|
|
184
|
+
}
|
|
185
|
+
expect(result.exitCode).toBe(0)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// ──────────────────────────────────────────────────────────────
|
|
189
|
+
// 4. Validate: `dco.sh validate` should pass
|
|
190
|
+
// ──────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
it('dco.sh validate passes on fully signed repo', async function () {
|
|
193
|
+
const result = await spawn(
|
|
194
|
+
['bash', DCO_SH, 'validate', '', 'HEAD'],
|
|
195
|
+
{ cwd: repoDir }
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if (result.exitCode !== 0) {
|
|
199
|
+
console.error('validate output:', result.output)
|
|
200
|
+
}
|
|
201
|
+
expect(result.exitCode).toBe(0)
|
|
202
|
+
expect(result.output).toContain('All commits are properly signed!')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// ──────────────────────────────────────────────────────────────
|
|
206
|
+
// 5. Negative: unsigned commit should fail validation
|
|
207
|
+
// ──────────────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
it('dco.sh validate fails on unsigned commit', async function () {
|
|
210
|
+
// Create a commit WITHOUT --signoff
|
|
211
|
+
await writeFile(join(repoDir, 'unsigned.txt'), 'no signoff\n')
|
|
212
|
+
await spawn(['git', 'add', '-A'], { cwd: repoDir })
|
|
213
|
+
await spawn(['git', 'commit', '-m', 'bad: no signoff'], { cwd: repoDir })
|
|
214
|
+
|
|
215
|
+
const result = await spawn(
|
|
216
|
+
['bash', DCO_SH, 'validate', '', 'HEAD'],
|
|
217
|
+
{ cwd: repoDir }
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
expect(result.exitCode).not.toBe(0)
|
|
221
|
+
expect(result.output).toContain('DCO validation failed')
|
|
222
|
+
})
|
|
223
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stream44.studio/dco",
|
|
3
|
+
"version": "0.3.0-rc.2",
|
|
4
|
+
"description": "Developer Certificate of Origin (DCO) tools for signing & verifying.",
|
|
5
|
+
"private": false,
|
|
6
|
+
"keywords": [
|
|
7
|
+
"dco",
|
|
8
|
+
"developer-certificate-of-origin",
|
|
9
|
+
"git",
|
|
10
|
+
"commit",
|
|
11
|
+
"signoff",
|
|
12
|
+
"Terminal44.sh",
|
|
13
|
+
"t44",
|
|
14
|
+
"Stream44.Studio",
|
|
15
|
+
"studio"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/Stream44/dco.git"
|
|
20
|
+
},
|
|
21
|
+
"license": "Apache-2.0",
|
|
22
|
+
"bin": {
|
|
23
|
+
"@stream44.studio/dco": "./dco.sh"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"sign": "./commit.sh",
|
|
27
|
+
"test": "./test.sh",
|
|
28
|
+
"validate": "./validate.sh"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@stream44.studio/encapsulate": "^0.4.0-rc.2",
|
|
32
|
+
"t44": "^0.4.0-rc.2"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "^1.3.4",
|
|
36
|
+
"@types/node": "^25.0.3",
|
|
37
|
+
"bun-types": "^1.3.4"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/test.sh
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# DCO Commit Script Test
|
|
4
|
+
# ======================
|
|
5
|
+
# Tests the commit.sh workflow to ensure proper DCO handling
|
|
6
|
+
#
|
|
7
|
+
|
|
8
|
+
set -e
|
|
9
|
+
|
|
10
|
+
# Colors for output
|
|
11
|
+
readonly RED='\033[0;31m'
|
|
12
|
+
readonly GREEN='\033[0;32m'
|
|
13
|
+
readonly YELLOW='\033[1;33m'
|
|
14
|
+
readonly BLUE='\033[0;34m'
|
|
15
|
+
readonly CYAN='\033[0;36m'
|
|
16
|
+
readonly NC='\033[0m'
|
|
17
|
+
readonly BOLD='\033[1m'
|
|
18
|
+
|
|
19
|
+
# Test directory
|
|
20
|
+
readonly TEST_DIR="$PWD/.~test"
|
|
21
|
+
readonly TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
|
22
|
+
readonly TEST_PATH="$TEST_DIR/$TIMESTAMP"
|
|
23
|
+
|
|
24
|
+
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
25
|
+
echo -e "${BOLD}${CYAN} DCO Commit Script Test${NC}"
|
|
26
|
+
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
27
|
+
echo
|
|
28
|
+
|
|
29
|
+
# Cleanup function
|
|
30
|
+
cleanup() {
|
|
31
|
+
if [[ -d "$TEST_PATH" ]]; then
|
|
32
|
+
echo -e "${BLUE}Cleaning up test directory...${NC}"
|
|
33
|
+
rm -rf "$TEST_PATH"
|
|
34
|
+
fi
|
|
35
|
+
# Clean up signed repo and keys dirs
|
|
36
|
+
rm -rf "$TEST_DIR/$TIMESTAMP-signed" "$TEST_DIR/$TIMESTAMP-keys" 2>/dev/null || true
|
|
37
|
+
# Also remove parent test directory if empty
|
|
38
|
+
if [[ -d "$TEST_DIR" ]] && [[ -z "$(ls -A "$TEST_DIR" 2>/dev/null)" ]]; then
|
|
39
|
+
rm -rf "$TEST_DIR"
|
|
40
|
+
fi
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Setup trap for cleanup
|
|
44
|
+
trap cleanup EXIT
|
|
45
|
+
|
|
46
|
+
# Create test directory
|
|
47
|
+
echo -e "${BLUE}Setting up test environment: $TEST_PATH${NC}"
|
|
48
|
+
mkdir -p "$TEST_PATH"
|
|
49
|
+
|
|
50
|
+
# Get the directory where this test script is located
|
|
51
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
52
|
+
|
|
53
|
+
# Copy files to test directory (excluding .dco-signatures and test.sh)
|
|
54
|
+
echo -e "${BLUE}Copying files to test directory...${NC}"
|
|
55
|
+
for file in "$SCRIPT_DIR"/*; do
|
|
56
|
+
filename=$(basename "$file")
|
|
57
|
+
# Skip .dco-signatures, test.sh, and directories
|
|
58
|
+
if [[ "$filename" != ".dco-signatures" ]] && \
|
|
59
|
+
[[ "$filename" != "test.sh" ]] && \
|
|
60
|
+
[[ "$filename" != ".git" ]] && \
|
|
61
|
+
[[ -f "$file" ]]; then
|
|
62
|
+
cp "$file" "$TEST_PATH/"
|
|
63
|
+
echo -e " Copied: $filename"
|
|
64
|
+
fi
|
|
65
|
+
done
|
|
66
|
+
|
|
67
|
+
# Copy .github directory if it exists
|
|
68
|
+
if [[ -d "$SCRIPT_DIR/.github" ]]; then
|
|
69
|
+
cp -r "$SCRIPT_DIR/.github" "$TEST_PATH/"
|
|
70
|
+
echo -e " Copied: .github/"
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
# Copy github-action directory if it exists
|
|
74
|
+
if [[ -d "$SCRIPT_DIR/github-action" ]]; then
|
|
75
|
+
cp -r "$SCRIPT_DIR/github-action" "$TEST_PATH/"
|
|
76
|
+
echo -e " Copied: github-action/"
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
echo
|
|
80
|
+
|
|
81
|
+
# Change to test directory
|
|
82
|
+
cd "$TEST_PATH"
|
|
83
|
+
|
|
84
|
+
# Initialize git repository
|
|
85
|
+
echo -e "${BLUE}Initializing git repository...${NC}"
|
|
86
|
+
git init 2>&1
|
|
87
|
+
git config user.name "Test User"
|
|
88
|
+
git config user.email "test@example.com"
|
|
89
|
+
# Ensure we're on main branch
|
|
90
|
+
git checkout -b main 2>/dev/null || git branch -M main 2>/dev/null || true
|
|
91
|
+
echo -e "${GREEN}✓ Git repository initialized${NC}"
|
|
92
|
+
echo
|
|
93
|
+
|
|
94
|
+
# Create some test files
|
|
95
|
+
echo -e "${BLUE}Creating test files...${NC}"
|
|
96
|
+
echo "# Test Project" > README-TEST.md
|
|
97
|
+
echo "console.log('test');" > test.js
|
|
98
|
+
echo -e "${GREEN}✓ Test files created${NC}"
|
|
99
|
+
echo
|
|
100
|
+
|
|
101
|
+
# Stage all files
|
|
102
|
+
echo -e "${BLUE}Staging all files...${NC}"
|
|
103
|
+
git add .
|
|
104
|
+
echo -e "${GREEN}✓ Files staged${NC}"
|
|
105
|
+
echo
|
|
106
|
+
|
|
107
|
+
# Run commit script with --yes-signoff (sign-only, no user commit)
|
|
108
|
+
echo -e "${BOLD}${YELLOW}Running commit.sh with --yes-signoff (sign only)...${NC}"
|
|
109
|
+
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
110
|
+
./commit.sh --yes-signoff
|
|
111
|
+
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
112
|
+
echo
|
|
113
|
+
|
|
114
|
+
# Now commit user code separately
|
|
115
|
+
echo -e "${BOLD}${YELLOW}Committing user code with --signoff...${NC}"
|
|
116
|
+
git commit --signoff -m "Initial test commit"
|
|
117
|
+
echo
|
|
118
|
+
|
|
119
|
+
# Verify the results
|
|
120
|
+
echo -e "${BOLD}${CYAN}Verifying results...${NC}"
|
|
121
|
+
echo
|
|
122
|
+
|
|
123
|
+
# Check number of commits (1 DCO.md + 1 DCO signature + 1 user commit = 3)
|
|
124
|
+
COMMIT_COUNT=$(git rev-list --count HEAD)
|
|
125
|
+
echo -e "${BLUE}Commit count: ${BOLD}$COMMIT_COUNT${NC}"
|
|
126
|
+
|
|
127
|
+
if [[ $COMMIT_COUNT -ne 3 ]]; then
|
|
128
|
+
echo -e "${RED}✗ FAIL: Expected 3 commits, found $COMMIT_COUNT${NC}"
|
|
129
|
+
echo
|
|
130
|
+
echo -e "${YELLOW}Git log:${NC}"
|
|
131
|
+
git log --oneline
|
|
132
|
+
exit 1
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
echo -e "${GREEN}✓ Correct number of commits (3)${NC}"
|
|
136
|
+
echo
|
|
137
|
+
|
|
138
|
+
# Check first commit (DCO.md auto-committed by commit.sh)
|
|
139
|
+
echo -e "${BLUE}Checking first commit (DCO.md auto-commit):${NC}"
|
|
140
|
+
FIRST_COMMIT=$(git rev-list --max-parents=0 HEAD)
|
|
141
|
+
FIRST_MSG=$(git log -1 --format='%s' "$FIRST_COMMIT")
|
|
142
|
+
FIRST_BODY=$(git log -1 --format='%b' "$FIRST_COMMIT")
|
|
143
|
+
FIRST_FILES=$(git diff-tree --no-commit-id --name-only --root -r "$FIRST_COMMIT")
|
|
144
|
+
|
|
145
|
+
echo -e " Subject: ${CYAN}$FIRST_MSG${NC}"
|
|
146
|
+
|
|
147
|
+
if [[ ! "$FIRST_MSG" =~ ^\[DCO\]\ Set\ DCO\.md\ Policy\ by\ .+ ]]; then
|
|
148
|
+
echo -e "${RED}✗ FAIL: First commit should be '[DCO] Set DCO.md Policy by <Name>', got: $FIRST_MSG${NC}"
|
|
149
|
+
exit 1
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
if [[ ! "$FIRST_BODY" =~ Signed-off-by ]]; then
|
|
153
|
+
echo -e "${RED}✗ FAIL: First commit missing Signed-off-by trailer${NC}"
|
|
154
|
+
exit 1
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
echo -e "${GREEN}✓ First commit auto-commits DCO.md with --signoff${NC}"
|
|
158
|
+
echo
|
|
159
|
+
|
|
160
|
+
# Check second commit (DCO signature)
|
|
161
|
+
echo -e "${BLUE}Checking second commit (DCO signature):${NC}"
|
|
162
|
+
SIG_COMMIT=$(git rev-list HEAD | tail -2 | head -1)
|
|
163
|
+
SIG_MSG=$(git log -1 --format='%s' "$SIG_COMMIT")
|
|
164
|
+
SIG_BODY=$(git log -1 --format='%b' "$SIG_COMMIT")
|
|
165
|
+
SIG_FILES=$(git diff-tree --no-commit-id --name-only -r "$SIG_COMMIT")
|
|
166
|
+
|
|
167
|
+
echo -e " Subject: ${CYAN}$SIG_MSG${NC}"
|
|
168
|
+
|
|
169
|
+
if [[ ! "$SIG_MSG" =~ ^\[DCO\]\ DCO\.md\ signed\ by\ .+ ]]; then
|
|
170
|
+
echo -e "${RED}✗ FAIL: Second commit should be '[DCO] DCO.md signed by <Name>', got: $SIG_MSG${NC}"
|
|
171
|
+
exit 1
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
if [[ ! "$SIG_BODY" =~ Signed-off-by ]]; then
|
|
175
|
+
echo -e "${RED}✗ FAIL: Second commit missing Signed-off-by trailer${NC}"
|
|
176
|
+
exit 1
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
# Check that signature commit only contains .dco-signatures
|
|
180
|
+
echo -e " Files in commit:"
|
|
181
|
+
echo "$SIG_FILES" | while read -r f; do echo -e " - $f"; done
|
|
182
|
+
|
|
183
|
+
if [[ "$SIG_FILES" != ".dco-signatures" ]]; then
|
|
184
|
+
echo -e "${RED}✗ FAIL: Signature commit should only contain .dco-signatures${NC}"
|
|
185
|
+
echo -e "${RED} Expected: .dco-signatures${NC}"
|
|
186
|
+
echo -e "${RED} Found:${NC}"
|
|
187
|
+
echo "$SIG_FILES" | while read -r f; do echo -e "${RED} - $f${NC}"; done
|
|
188
|
+
exit 1
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
echo -e "${GREEN}✓ Second commit is valid DCO signature commit${NC}"
|
|
192
|
+
echo -e "${GREEN}✓ Contains only .dco-signatures file${NC}"
|
|
193
|
+
echo
|
|
194
|
+
|
|
195
|
+
# Check third commit (actual user changes)
|
|
196
|
+
echo -e "${BLUE}Checking third commit (user changes):${NC}"
|
|
197
|
+
THIRD_COMMIT=$(git rev-list HEAD | head -1)
|
|
198
|
+
THIRD_MSG=$(git log -1 --format='%s' "$THIRD_COMMIT")
|
|
199
|
+
THIRD_BODY=$(git log -1 --format='%b' "$THIRD_COMMIT")
|
|
200
|
+
THIRD_FILES=$(git diff-tree --no-commit-id --name-only -r "$THIRD_COMMIT" | sort)
|
|
201
|
+
|
|
202
|
+
echo -e " Subject: ${CYAN}$THIRD_MSG${NC}"
|
|
203
|
+
|
|
204
|
+
if [[ "$THIRD_MSG" != "Initial test commit" ]]; then
|
|
205
|
+
echo -e "${RED}✗ FAIL: Third commit message incorrect${NC}"
|
|
206
|
+
exit 1
|
|
207
|
+
fi
|
|
208
|
+
|
|
209
|
+
if [[ ! "$THIRD_BODY" =~ Signed-off-by ]]; then
|
|
210
|
+
echo -e "${RED}✗ FAIL: Third commit missing Signed-off-by trailer${NC}"
|
|
211
|
+
exit 1
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
echo -e "${GREEN}✓ Third commit message is correct${NC}"
|
|
215
|
+
echo -e "${GREEN}✓ Third commit has Signed-off-by trailer${NC}"
|
|
216
|
+
echo
|
|
217
|
+
|
|
218
|
+
# Verify .dco-signatures file exists and has content
|
|
219
|
+
if [[ ! -f ".dco-signatures" ]]; then
|
|
220
|
+
echo -e "${RED}✗ FAIL: .dco-signatures file not found${NC}"
|
|
221
|
+
exit 1
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
if ! grep -q "Test User.*test@example.com" ".dco-signatures"; then
|
|
225
|
+
echo -e "${RED}✗ FAIL: .dco-signatures doesn't contain correct signature${NC}"
|
|
226
|
+
exit 1
|
|
227
|
+
fi
|
|
228
|
+
|
|
229
|
+
# Verify signature line format: name <email> | signed: <date> | agreement: <commit> (<dco_change_date>)
|
|
230
|
+
SIG_LINE=$(grep "Test User" ".dco-signatures")
|
|
231
|
+
echo -e " Signature: ${CYAN}$SIG_LINE${NC}"
|
|
232
|
+
|
|
233
|
+
if [[ ! "$SIG_LINE" =~ \|\ signed: ]]; then
|
|
234
|
+
echo -e "${RED}✗ FAIL: Signature missing 'signed:' field${NC}"
|
|
235
|
+
exit 1
|
|
236
|
+
fi
|
|
237
|
+
|
|
238
|
+
if [[ ! "$SIG_LINE" =~ \|\ agreement:\ [a-f0-9] ]]; then
|
|
239
|
+
echo -e "${RED}✗ FAIL: Signature missing 'agreement:' commit reference${NC}"
|
|
240
|
+
exit 1
|
|
241
|
+
fi
|
|
242
|
+
|
|
243
|
+
# Extract and verify the agreement commit from the signature
|
|
244
|
+
SIG_AGREEMENT_COMMIT=$(echo "$SIG_LINE" | sed -n 's/.*| agreement: \([a-f0-9]*\).*/\1/p')
|
|
245
|
+
if ! git cat-file -e "$SIG_AGREEMENT_COMMIT" 2>/dev/null; then
|
|
246
|
+
echo -e "${RED}✗ FAIL: Agreement commit $SIG_AGREEMENT_COMMIT in signature does not exist${NC}"
|
|
247
|
+
exit 1
|
|
248
|
+
fi
|
|
249
|
+
|
|
250
|
+
if ! git show "$SIG_AGREEMENT_COMMIT:DCO.md" >/dev/null 2>&1; then
|
|
251
|
+
echo -e "${RED}✗ FAIL: DCO.md not found in referenced commit $SIG_AGREEMENT_COMMIT${NC}"
|
|
252
|
+
exit 1
|
|
253
|
+
fi
|
|
254
|
+
|
|
255
|
+
echo -e "${GREEN}✓ .dco-signatures file contains correct signature${NC}"
|
|
256
|
+
echo -e "${GREEN}✓ Signature format is valid (single line with name, date, agreement commit)${NC}"
|
|
257
|
+
echo -e "${GREEN}✓ Agreement commit reference is valid and contains DCO.md${NC}"
|
|
258
|
+
echo
|
|
259
|
+
|
|
260
|
+
# Display full git log
|
|
261
|
+
echo -e "${BOLD}${CYAN}Full git log:${NC}"
|
|
262
|
+
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
263
|
+
git log --format="%h - %s%n Author: %an <%ae>%n %b" | head -20
|
|
264
|
+
echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
265
|
+
echo
|
|
266
|
+
|
|
267
|
+
# Test re-running sign (should show already signed with details)
|
|
268
|
+
echo -e "${BOLD}${YELLOW}Testing re-sign (should show already signed)...${NC}"
|
|
269
|
+
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
270
|
+
RESIGN_OUTPUT=$(./commit.sh --yes-signoff 2>&1)
|
|
271
|
+
echo "$RESIGN_OUTPUT"
|
|
272
|
+
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
273
|
+
echo
|
|
274
|
+
|
|
275
|
+
if ! echo "$RESIGN_OUTPUT" | grep -q "DCO Already Signed"; then
|
|
276
|
+
echo -e "${RED}✗ FAIL: Re-sign should show 'DCO Already Signed'${NC}"
|
|
277
|
+
exit 1
|
|
278
|
+
fi
|
|
279
|
+
|
|
280
|
+
if ! echo "$RESIGN_OUTPUT" | grep -q "Signer:"; then
|
|
281
|
+
echo -e "${RED}✗ FAIL: Re-sign should show signer details${NC}"
|
|
282
|
+
exit 1
|
|
283
|
+
fi
|
|
284
|
+
|
|
285
|
+
echo -e "${GREEN}✓ Re-sign correctly shows already signed with details${NC}"
|
|
286
|
+
echo
|
|
287
|
+
|
|
288
|
+
# Test second user commit (sign + commit separately)
|
|
289
|
+
echo -e "${BOLD}${YELLOW}Testing second user commit...${NC}"
|
|
290
|
+
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
291
|
+
echo "# Second change" >> README-TEST.md
|
|
292
|
+
git add README-TEST.md
|
|
293
|
+
git commit --signoff -m "Second commit"
|
|
294
|
+
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
295
|
+
echo
|
|
296
|
+
|
|
297
|
+
# Verify we now have 4 commits total (1 DCO.md + 1 DCO sig + 1 first user + 1 second user)
|
|
298
|
+
COMMIT_COUNT=$(git rev-list --count HEAD)
|
|
299
|
+
if [[ $COMMIT_COUNT -ne 4 ]]; then
|
|
300
|
+
echo -e "${RED}✗ FAIL: Expected 4 commits after second commit, found $COMMIT_COUNT${NC}"
|
|
301
|
+
exit 1
|
|
302
|
+
fi
|
|
303
|
+
|
|
304
|
+
echo -e "${GREEN}✓ Second user commit successful (no new DCO signature commit)${NC}"
|
|
305
|
+
echo
|
|
306
|
+
|
|
307
|
+
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
308
|
+
echo
|
|
309
|
+
|
|
310
|
+
# ══════════════════════════════════════════════════════════════════
|
|
311
|
+
# Test: --require-signatures with SSH-signed commits
|
|
312
|
+
# ══════════════════════════════════════════════════════════════════
|
|
313
|
+
|
|
314
|
+
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
315
|
+
echo -e "${BOLD}${CYAN} Testing --require-signatures (SSH-signed DCO)${NC}"
|
|
316
|
+
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
317
|
+
echo
|
|
318
|
+
|
|
319
|
+
# Create a fresh repo for the SSH signing test
|
|
320
|
+
SIGNED_REPO="$TEST_DIR/$TIMESTAMP-signed"
|
|
321
|
+
mkdir -p "$SIGNED_REPO"
|
|
322
|
+
|
|
323
|
+
# Generate an SSH signing key
|
|
324
|
+
SSH_KEY_DIR="$TEST_DIR/$TIMESTAMP-keys"
|
|
325
|
+
mkdir -p "$SSH_KEY_DIR"
|
|
326
|
+
ssh-keygen -t ed25519 -f "$SSH_KEY_DIR/test_signing_ed25519" -N "" -q
|
|
327
|
+
SIGNING_KEY="$SSH_KEY_DIR/test_signing_ed25519"
|
|
328
|
+
SIGNING_FP=$(ssh-keygen -lf "$SIGNING_KEY" | awk '{print $2}')
|
|
329
|
+
echo -e "${BLUE}Generated SSH signing key: $SIGNING_FP${NC}"
|
|
330
|
+
|
|
331
|
+
# Copy files to signed repo
|
|
332
|
+
for file in "$SCRIPT_DIR"/*; do
|
|
333
|
+
filename=$(basename "$file")
|
|
334
|
+
if [[ "$filename" != ".dco-signatures" ]] && \
|
|
335
|
+
[[ "$filename" != "test.sh" ]] && \
|
|
336
|
+
[[ "$filename" != ".git" ]] && \
|
|
337
|
+
[[ -f "$file" ]]; then
|
|
338
|
+
cp "$file" "$SIGNED_REPO/"
|
|
339
|
+
fi
|
|
340
|
+
done
|
|
341
|
+
|
|
342
|
+
cd "$SIGNED_REPO"
|
|
343
|
+
git init -q
|
|
344
|
+
git config user.name "Test Signer"
|
|
345
|
+
git config user.email "signer@example.com"
|
|
346
|
+
git checkout -b main 2>/dev/null || git branch -M main 2>/dev/null || true
|
|
347
|
+
|
|
348
|
+
# Create test files
|
|
349
|
+
echo "# Signed Test" > README.md
|
|
350
|
+
git add .
|
|
351
|
+
|
|
352
|
+
# Run commit.sh with --signing-key
|
|
353
|
+
echo -e "${BOLD}${YELLOW}Running commit.sh with --signing-key ...${NC}"
|
|
354
|
+
./commit.sh --yes-signoff --signing-key "$SIGNING_KEY"
|
|
355
|
+
echo
|
|
356
|
+
|
|
357
|
+
# Commit user code with SSH signing
|
|
358
|
+
git add -A
|
|
359
|
+
git -c gpg.format=ssh -c "user.signingkey=$SIGNING_KEY" commit --gpg-sign --signoff -m "Signed user commit"
|
|
360
|
+
echo
|
|
361
|
+
|
|
362
|
+
# Verify .dco-signatures has the fingerprint
|
|
363
|
+
SIG_LINE=$(grep "Test Signer" ".dco-signatures")
|
|
364
|
+
echo -e " Signature line: ${CYAN}$SIG_LINE${NC}"
|
|
365
|
+
|
|
366
|
+
if [[ ! "$SIG_LINE" =~ \|\ signature:\ SHA256: ]]; then
|
|
367
|
+
echo -e "${RED}✗ FAIL: .dco-signatures should contain 'signature: SHA256:...' when signing key is used${NC}"
|
|
368
|
+
exit 1
|
|
369
|
+
fi
|
|
370
|
+
echo -e "${GREEN}✓ .dco-signatures contains SSH key fingerprint${NC}"
|
|
371
|
+
echo
|
|
372
|
+
|
|
373
|
+
# Verify the fingerprint in .dco-signatures matches the actual key
|
|
374
|
+
SIG_FILE_FP=$(echo "$SIG_LINE" | sed -n 's/.*| signature: \([^ ]*\).*/\1/p')
|
|
375
|
+
if [[ "$SIG_FILE_FP" != "$SIGNING_FP" ]]; then
|
|
376
|
+
echo -e "${RED}✗ FAIL: Fingerprint in .dco-signatures ($SIG_FILE_FP) does not match signing key ($SIGNING_FP)${NC}"
|
|
377
|
+
exit 1
|
|
378
|
+
fi
|
|
379
|
+
echo -e "${GREEN}✓ Fingerprint in .dco-signatures matches the signing key${NC}"
|
|
380
|
+
echo
|
|
381
|
+
|
|
382
|
+
# Test validate.sh --enforce-signature-fingerprints should FAIL on unsigned commits
|
|
383
|
+
echo -e "${BOLD}${YELLOW}Testing --enforce-signature-fingerprints on unsigned repo (should fail)...${NC}"
|
|
384
|
+
cd "$TEST_PATH"
|
|
385
|
+
if ./validate.sh "" HEAD --enforce-signature-fingerprints >/dev/null 2>&1; then
|
|
386
|
+
echo -e "${RED}✗ FAIL: --enforce-signature-fingerprints should fail on unsigned commits${NC}"
|
|
387
|
+
exit 1
|
|
388
|
+
fi
|
|
389
|
+
echo -e "${GREEN}✓ --enforce-signature-fingerprints correctly fails on unsigned commits${NC}"
|
|
390
|
+
echo
|
|
391
|
+
|
|
392
|
+
# Test validate.sh --enforce-signature-fingerprints on the signed repo (should pass)
|
|
393
|
+
echo -e "${BOLD}${YELLOW}Testing --enforce-signature-fingerprints on signed repo...${NC}"
|
|
394
|
+
cd "$SIGNED_REPO"
|
|
395
|
+
|
|
396
|
+
# Set up allowed signers for git to verify SSH signatures
|
|
397
|
+
ALLOWED_SIGNERS_FILE="$SSH_KEY_DIR/allowed_signers"
|
|
398
|
+
echo "signer@example.com $(cat "$SIGNING_KEY.pub")" > "$ALLOWED_SIGNERS_FILE"
|
|
399
|
+
git config gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS_FILE"
|
|
400
|
+
|
|
401
|
+
VALIDATE_OUTPUT=$(./validate.sh "" HEAD --enforce-signature-fingerprints 2>&1) || {
|
|
402
|
+
echo -e "${RED}✗ FAIL: --enforce-signature-fingerprints should pass on signed repo${NC}"
|
|
403
|
+
echo "$VALIDATE_OUTPUT"
|
|
404
|
+
exit 1
|
|
405
|
+
}
|
|
406
|
+
echo "$VALIDATE_OUTPUT"
|
|
407
|
+
|
|
408
|
+
if ! echo "$VALIDATE_OUTPUT" | grep -q "Signature fingerprints: enforced"; then
|
|
409
|
+
echo -e "${RED}✗ FAIL: Output should contain 'Signature fingerprints: enforced'${NC}"
|
|
410
|
+
exit 1
|
|
411
|
+
fi
|
|
412
|
+
echo -e "${GREEN}✓ --enforce-signature-fingerprints passes on SSH-signed repo with matching fingerprints${NC}"
|
|
413
|
+
echo
|
|
414
|
+
|
|
415
|
+
# Final success message
|
|
416
|
+
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
417
|
+
echo -e "${BOLD}${GREEN} ✓ ALL TESTS PASSED!${NC}"
|
|
418
|
+
echo -e "${BOLD}${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
|
419
|
+
echo
|
|
420
|
+
echo -e "${BLUE}Test directory: $TEST_PATH${NC}"
|
|
421
|
+
echo -e "${BLUE}(Will be cleaned up on exit)${NC}"
|
|
422
|
+
echo
|