diff --git a/.buildkite/scripts/product-validation.sh b/.buildkite/scripts/product-validation.sh index 807c9a5..a3a28e5 100755 --- a/.buildkite/scripts/product-validation.sh +++ b/.buildkite/scripts/product-validation.sh @@ -28,7 +28,7 @@ bash .buildkite/scripts/checkpoint-evidence.sh repository_id="${TABELLIO_REPO_ID:-}" if [[ -z "$repository_id" ]]; then repository_url="$(git remote get-url origin)" - repository_id="$(node -e 'const u=process.argv[1]; const m=u.match(/^(?:https:\/\/github\.com\/|git@github\.com:)([^/]+\/[^/]+?)(?:\.git)?$/); if(!m)process.exit(1); console.log(m[1]);' "$repository_url")" + repository_id="$(node --input-type=module -e 'import { parseGitHubRepositoryRemote } from "./scripts/lib/github-repository.mjs"; const repository = parseGitHubRepositoryRemote(process.argv[1]); if (!repository) process.exit(1); console.log(repository.fullName);' "$repository_url")" fi [[ "$repository_id" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { echo "A valid customer repository ID is required." >&2; exit 1; } diff --git a/scripts/ci-checkpoint-evidence.mjs b/scripts/ci-checkpoint-evidence.mjs index 92f66d7..1826d11 100644 --- a/scripts/ci-checkpoint-evidence.mjs +++ b/scripts/ci-checkpoint-evidence.mjs @@ -28,11 +28,15 @@ try { const text = (process.env.TABELLIO_CHECKPOINT_PROOF_1 ?? '') + (process.env.TABELLIO_CHECKPOINT_PROOF_2 ?? ''); if (!text || text.length > 98000) throw new Error('Missing or oversized evidence.'); const envelope = JSON.parse(text); + // Keep the transport range aligned with the validation gate after main advances. + const base = process.env.GITHUB_EVENT_NAME === 'push' + ? git('rev-parse', 'HEAD^') + : git('merge-base', 'origin/main', 'HEAD'); if (Object.keys(envelope).sort().join(',') !== 'base,bundle,candidate,nativeTip,repositoryId,schemaVersion,sha256' || envelope.schemaVersion !== 'tabellio-private-checkpoint-proof/v1' || envelope.repositoryId !== `github.com/${process.env.GITHUB_REPOSITORY}` || envelope.candidate !== git('rev-parse', 'HEAD') - || envelope.base !== git('merge-base', 'origin/main', 'HEAD') + || envelope.base !== base || !/^[0-9a-f]{40}$/.test(envelope.nativeTip)) throw new Error('Evidence scope mismatch.'); const bytes = Buffer.from(envelope.bundle, 'base64'); if (bytes.toString('base64') !== envelope.bundle || createHash('sha256').update(bytes).digest('hex') !== envelope.sha256) throw new Error('Evidence integrity mismatch.'); diff --git a/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index ef400b6..5d5b7d0 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -140,7 +140,7 @@ test("GitHub merged-head validation remains during Buildkite migration", async ( const loader = await repositoryFile("scripts/ci-checkpoint-evidence.mjs"); assert.match(loader, /execFileSync\('bash', \['\.buildkite\/scripts\/checkpoint-evidence\.sh'\]/); assert.match(loader, /envelope\.candidate !== git\('rev-parse', 'HEAD'\)/); - assert.match(loader, /envelope\.base !== git\('merge-base', 'origin\/main', 'HEAD'\)/); + assert.match(loader, /envelope\.base !== base/); assert.match(loader, /createHash\('sha256'\)\.update\(bytes\)\.digest\('hex'\) !== envelope\.sha256/); assert.doesNotMatch(workflow, /refs\/tabellio\/validations\//); assert.doesNotMatch(workflow, /validation-ref\.bundle/); diff --git a/tests/checkpoint-evidence.test.mjs b/tests/checkpoint-evidence.test.mjs index 924b43e..f7b2110 100644 --- a/tests/checkpoint-evidence.test.mjs +++ b/tests/checkpoint-evidence.test.mjs @@ -117,11 +117,25 @@ test('private runner envelope binds candidate and digest, rejects leaks, and rem const loader = resolve('scripts/ci-checkpoint-evidence.mjs'); const invoke = (command, value = envelope) => { const text = JSON.stringify(value); const middle = Math.floor(text.length / 2); - return run(target, process.execPath, [loader, command], { GITHUB_ACTIONS: 'true', GITHUB_WORKSPACE: target, RUNNER_TEMP: runner, GITHUB_REPOSITORY: 'Fixture/Transport', TABELLIO_CHECKPOINT_PROOF_1: text.slice(0, middle), TABELLIO_CHECKPOINT_PROOF_2: text.slice(middle) }); + return run(target, process.execPath, [loader, command], { GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'pull_request', GITHUB_WORKSPACE: target, RUNNER_TEMP: runner, GITHUB_REPOSITORY: 'Fixture/Transport', TABELLIO_CHECKPOINT_PROOF_1: text.slice(0, middle), TABELLIO_CHECKPOINT_PROOF_2: text.slice(middle) }); }; for (const bad of [{ ...envelope, candidate: base }, { ...envelope, sha256: '0'.repeat(64) }, { ...envelope, repositoryId: 'wrong/private-secret-marker' }]) { const r = invoke('load', bad); assert.equal(r.status, 1); assert.ok(!r.stderr.includes('private-secret-marker')); } + // A main push advances origin/main to HEAD; the gate still validates HEAD^..HEAD. + git(target, 'update-ref', 'refs/remotes/origin/main', candidate); + const invokePush = value => { + const text = JSON.stringify(value); const middle = Math.floor(text.length / 2); + return run(target, process.execPath, [loader, 'load'], { GITHUB_ACTIONS: 'true', GITHUB_EVENT_NAME: 'push', GITHUB_WORKSPACE: target, RUNNER_TEMP: runner, GITHUB_REPOSITORY: 'Fixture/Transport', TABELLIO_CHECKPOINT_PROOF_1: text.slice(0, middle), TABELLIO_CHECKPOINT_PROOF_2: text.slice(middle) }); + }; + assert.equal(invokePush({ ...envelope, base: candidate }).status, 1, 'HEAD is not the prior push base'); + assert.equal(invokePush({ ...envelope, repositoryId: 'github.com/Wrong/Repository' }).status, 1); + assert.equal(invokePush({ ...envelope, candidate: base }).status, 1); + assert.equal(invokePush(envelope).status, 0, 'prior-base proof loads after origin/main advances'); + assert.equal(git(target, 'rev-parse', 'refs/heads/entire/checkpoints/v1'), nativeTip); + assert.equal(invoke('cleanup').status, 0); + git(target, 'update-ref', 'refs/remotes/origin/main', base); + assert.equal(invoke('load', { ...envelope, base: candidate }).status, 1, 'PR proof rejects the wrong base'); const loaded = invoke('load'); assert.equal(loaded.status, 0, loaded.stderr); assert.equal(git(target, 'rev-parse', 'refs/heads/entire/checkpoints/v1'), nativeTip); for (const path of ['prompt.txt', 'full.jsonl']) assert.notEqual(run(target, 'git', ['cat-file', '-e', git(source, 'rev-parse', `${nativeTip}:${path}`)]).status, 0); @@ -135,5 +149,30 @@ test('private runner envelope binds candidate and digest, rejects leaks, and rem git(target, 'update-ref', ref, base); assert.equal(invoke('load').status, 1); assert.equal(git(target, 'rev-parse', ref), base); git(target, 'update-ref', '-d', ref); } + git(target, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--allow-empty', '-m', 'Second PR candidate'); + const secondCandidate = git(target, 'rev-parse', 'HEAD'); + assert.equal(git(target, 'rev-parse', 'HEAD^'), candidate); + assert.equal(git(target, 'merge-base', 'origin/main', 'HEAD'), base); + assert.equal(invoke('load', { ...envelope, candidate: secondCandidate, base: candidate }).status, 1, 'multi-commit PR must reject HEAD^ in place of merge-base'); + assert.equal(invoke('load', { ...envelope, candidate: secondCandidate }).status, 0, 'multi-commit PR retains its merge-base'); + assert.equal(invoke('cleanup').status, 0); + assert.equal(git(target, 'rev-parse', 'HEAD'), secondCandidate); } finally { await rm(root, { recursive: true, force: true }); } }); + + +test('Buildkite repository extraction uses shared GitHub parser for HTTPS and SSH origins', async () => { + const source = await readFile(resolve('.buildkite/scripts/product-validation.sh'), 'utf8'); + const extraction = source.match(/node --input-type=module -e '([^']+)' "\$repository_url"/); + assert.ok(extraction, 'Exercise the registered shell command, not a copy of its parser'); + for (const remote of ['https://github.com/Fixture/Transport.git', 'git@github.com:Fixture/Transport.git', 'ssh://git@github.com/Fixture/Transport.git']) { + const result = run(process.cwd(), process.execPath, ['--input-type=module', '-e', extraction[1], remote]); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), 'Fixture/Transport'); + } + for (const remote of ['ssh://git@example.com/Fixture/Transport.git', 'https://github.com/Fixture/Transport/extra', 'ssh://git@github.com/../Transport.git', 'not-a-repository']) { + const result = run(process.cwd(), process.execPath, ['--input-type=module', '-e', extraction[1], remote]); + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + } +});