@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.
@@ -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