diff --git a/.github/workflows/client-v1-conformance.yml b/.github/workflows/client-v1-conformance.yml index 2204f8f5..0a1a8b0e 100644 --- a/.github/workflows/client-v1-conformance.yml +++ b/.github/workflows/client-v1-conformance.yml @@ -32,16 +32,66 @@ jobs: fetch-depth: 0 persist-credentials: false ref: ${{ github.sha }} + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + repository: OpenCoven/sdk + fetch-depth: 1 + persist-credentials: false + ref: ${{ vars.CLIENT_V1_CONFORMANCE_VALIDATOR_REVISION }} + path: .phase1-counterparts/sdk-validator - id: resolve name: Resolve and verify the producer revision env: OPENCOVEN_PRODUCER_REVISION_INPUT: ${{ inputs.producer_revision }} + OPENCOVEN_VALIDATOR_REVISION_INPUT: ${{ inputs.validator_revision }} + OPENCOVEN_PROTECTED_VALIDATOR_REVISION: ${{ vars.CLIENT_V1_CONFORMANCE_VALIDATOR_REVISION }} OPENCOVEN_DISPATCH_SHA: ${{ github.sha }} run: | set -euo pipefail requested="${OPENCOVEN_PRODUCER_REVISION_INPUT:-}" if [ -z "$requested" ]; then - requested="$OPENCOVEN_DISPATCH_SHA" + validator_revision="${OPENCOVEN_VALIDATOR_REVISION_INPUT:-}" + protected_validator_revision="${OPENCOVEN_PROTECTED_VALIDATOR_REVISION:-}" + case "$validator_revision" in + *[!0-9a-f]* | "" ) echo 'Validator input revision must be an exact lowercase 40-hex commit.' >&2; exit 1 ;; + esac + if [ "${#validator_revision}" -ne 40 ]; then + echo 'Validator input revision must be an exact lowercase 40-hex commit.' >&2 + exit 1 + fi + case "$protected_validator_revision" in + *[!0-9a-f]* | "" ) echo 'Protected workflow revision must be an exact lowercase 40-hex commit.' >&2; exit 1 ;; + esac + if [ "${#protected_validator_revision}" -ne 40 ]; then + echo 'Protected workflow revision must be an exact lowercase 40-hex commit.' >&2 + exit 1 + fi + if [ "$validator_revision" != "$protected_validator_revision" ]; then + echo 'inputs.validator_revision does not match the protected environment validator revision.' >&2 + exit 1 + fi + requested="$( + node --input-type=module --eval " + import { readFileSync } from 'node:fs'; + const lockPath = process.argv[1]; + let lock; + try { + lock = JSON.parse(readFileSync(lockPath, 'utf8')); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(\`Validator lock at \${lockPath} is missing or invalid JSON: \${message}\`); + process.exit(1); + } + const commit = lock?.evidenceProducer?.commit; + if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) { + console.error( + \`Validator lock at \${lockPath} must contain evidenceProducer.commit as an exact lowercase 40-hex commit.\`, + ); + process.exit(1); + } + process.stdout.write(commit); + " ".phase1-counterparts/sdk-validator/conformance/client-v1-cross-repository-lock.json" + )" fi case "$requested" in *[!0-9a-f]* | "" ) echo 'Producer revision must be an exact lowercase 40-hex commit.' >&2; exit 1 ;; @@ -55,7 +105,7 @@ jobs: exit 1 fi if ! git merge-base --is-ancestor "$requested" "$OPENCOVEN_DISPATCH_SHA"; then - echo 'Producer revision is not an ancestor of the dispatch ref; only merged revisions may be validated.' >&2 + echo 'Producer revision is not an ancestor of the dispatch ref; only merged revisions may be validated. Supply inputs.producer_revision if the protected validator has not been refreshed yet.' >&2 exit 1 fi printf 'revision=%s\n' "$requested" >> "$GITHUB_OUTPUT" diff --git a/docs/phase1-conformance.md b/docs/phase1-conformance.md index b9a9a1a3..d1e0cf92 100644 --- a/docs/phase1-conformance.md +++ b/docs/phase1-conformance.md @@ -2020,7 +2020,7 @@ revision authorities can therefore have different workflow hashes: | File | Bytes | SHA-256 | | --- | ---: | --- | -| `.github/workflows/client-v1-conformance.yml` | 180,386 | `be7f36a7ce1b3dd6834b565e33825c5af410c49ed0e193ffaffda91ef79125b3` | +| `.github/workflows/client-v1-conformance.yml` | 183,188 | `32349d3f2b3132bea44985693b8a2770f0c057b215e54eedb742613e6d1bb935` | | `scripts/contract-canary.mjs` | 40,618 | `a4c2fe0a5eb6a5ff4653de5374c34c0fb46907c6806a5d23b86d8b37206ef958` | | `scripts/executable-resolution.mjs` | 9,154 | `31e3c412ff8c835f14522f36a59e91f4a4ba82913210ae8e3b4455217503f430` | | `scripts/owned-temp-directory.mjs` | 7,762 | `95f546ef9ed614f2a0f55d356ddfc54c943fc53b595b4eebebfcbd4db68e5c0b` | diff --git a/src/client-v1-conformance-workflow.test.ts b/src/client-v1-conformance-workflow.test.ts index e38fce92..747ea81b 100644 --- a/src/client-v1-conformance-workflow.test.ts +++ b/src/client-v1-conformance-workflow.test.ts @@ -1,6 +1,6 @@ import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, relative, resolve } from 'node:path'; @@ -703,6 +703,7 @@ function runProducerResolve( repository: string, dispatchSha: string, requested: string | undefined, + validatorRevision = 'a'.repeat(40), ): ResolveOutcome { const outputPath = resolve(repository, 'github-output'); writeFileSync(outputPath, ''); @@ -714,6 +715,8 @@ function runProducerResolve( ...process.env, GITHUB_OUTPUT: outputPath, OPENCOVEN_DISPATCH_SHA: dispatchSha, + OPENCOVEN_VALIDATOR_REVISION_INPUT: validatorRevision, + OPENCOVEN_PROTECTED_VALIDATOR_REVISION: validatorRevision, ...(requested === undefined ? {} : { OPENCOVEN_PRODUCER_REVISION_INPUT: requested }), }, }); @@ -752,15 +755,28 @@ describe('producer revision ancestry gate', () => { git('-c', 'commit.gpgsign=false', 'commit', '--quiet', '--allow-empty', '-m', 'unmerged'); const unmerged = git('rev-parse', 'HEAD'); git('checkout', '--quiet', 'main'); + mkdirSync(resolve(repository, '.phase1-counterparts', 'sdk-validator', 'conformance'), { + recursive: true, + }); + writeFileSync( + resolve( + repository, + '.phase1-counterparts', + 'sdk-validator', + 'conformance', + 'client-v1-cross-repository-lock.json', + ), + `${JSON.stringify({ evidenceProducer: { commit: merged } })}\n`, + ); afterAll(() => { rmSync(repository, { force: true, recursive: true }); }); - test('defaults to the dispatch tip when no revision is requested', () => { + test('defaults to the validator-locked evidence producer commit when no revision is requested', () => { const outcome = runProducerResolve(script, repository, tip, undefined); expect(outcome.status).toBe(0); - expect(outcome.revision).toBe(tip); + expect(outcome.revision).toBe(merged); }); test('accepts an exact merged ancestor of the dispatch tip', () => { @@ -809,6 +825,28 @@ describe('producer revision ancestry gate', () => { expect(outcome.revision).toBe(''); expect(outcome.stderr).toContain('not a commit in this repository'); }); + + test('refuses when validator input revision does not match the protected validator revision', () => { + const outputPath = resolve(repository, 'github-output-mismatch'); + writeFileSync(outputPath, ''); + const result = spawnSync('bash', ['-c', script], { + cwd: repository, + encoding: 'utf8', + timeout: 20_000, + env: { + ...process.env, + GITHUB_OUTPUT: outputPath, + OPENCOVEN_DISPATCH_SHA: tip, + OPENCOVEN_VALIDATOR_REVISION_INPUT: 'a'.repeat(40), + OPENCOVEN_PROTECTED_VALIDATOR_REVISION: 'b'.repeat(40), + }, + }); + expect(result.status).toBe(1); + expect(result.stderr ?? '').toContain( + 'inputs.validator_revision does not match the protected environment validator revision.', + ); + expect(readFileSync(outputPath, 'utf8')).toBe(''); + }); }); describe.skipIf(!validatorAvailable)('protected client-v1 conformance workflow', () => {