diff --git a/.github/workflows/sdk-ci.yml b/.github/workflows/sdk-ci.yml index f783557..b67d98e 100644 --- a/.github/workflows/sdk-ci.yml +++ b/.github/workflows/sdk-ci.yml @@ -50,7 +50,7 @@ jobs: git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null && git cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null; then mapfile -d '' -t trust_changes < <(git diff --name-only --no-renames -z "$BASE_SHA" "$HEAD_SHA" -- \ - .github/workflows/sdk-ci.yml scripts/verify-pr-ci-evidence.mjs) + .github/workflows/sdk-ci.yml scripts/verify-pr-ci-evidence.mjs scripts/validate-readme-cover-assets.mjs package.json pnpm-lock.yaml) if (( ${#trust_changes[@]} == 0 )); then allowed=true fi @@ -89,10 +89,13 @@ jobs: path_class=must-full has_docs_assets=false has_reusable_source=false + has_cover_contract_change=false + declare -A cover_contract_paths=() cover_pattern='^\.github/assets/avatar-cover-(light|dark)-(en|zh-Hans)\.jpg$' + cover_generator='scripts/readme-cover.html' documentation_pattern='^(README(\.[[:alnum:]_-]+)?\.md|AGENTS\.md|packages/[[:alnum:]_.-]+/(README(\.[[:alnum:]_-]+)?|AGENTS)\.md|docs(/[[:alnum:]_.-]+)+\.(md|png|jpe?g|gif|webp|svg)|skills/[a-z0-9]+(-[a-z0-9]+)*/(SKILL\.md|references/[[:alnum:]_.-]+\.md|agents/openai\.yaml))$' reusable_source_pattern='^(src/|packages/|scripts/[[:alnum:]_.-]+\.mjs$|package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsconfig[^/]*\.json$|vite\.config\.[[:alnum:]_-]+$)' - must_full_pattern='^(scripts/readme-cover\.html|\.github/assets/bear-breed-profiles\.png|\.github/)' + must_full_pattern='^(package\.json|pnpm-lock\.yaml|scripts/validate-readme-cover-assets\.mjs|\.github/assets/bear-breed-profiles\.png|\.github/)' for (( index = 0; index < ${#changes[@]}; index += 2 )); do change_status=${changes[index]} @@ -114,11 +117,18 @@ jobs: fi fi - # The four generated README covers are the only .github paths - # eligible for docs-assets. Check them before the broad - # .github must-full rule so precedence cannot shadow them. + # A cover update is a bounded completeness rule, not a claim + # that these JPEGs are deterministically derived. Any change + # to the generator or one cover must update the generator and + # every cover as regular 100644 A/M files in this same diff. if [[ "$changed_path" =~ $cover_pattern ]]; then has_docs_assets=true + has_cover_contract_change=true + cover_contract_paths["$changed_path"]=1 + elif [[ "$changed_path" == "$cover_generator" ]]; then + has_docs_assets=true + has_cover_contract_change=true + cover_contract_paths["$changed_path"]=1 elif [[ "$changed_path" =~ $must_full_pattern ]]; then break elif [[ "$changed_path" =~ $documentation_pattern ]]; then @@ -130,6 +140,22 @@ jobs: fi done + if [[ "$index" -eq "${#changes[@]}" && "$has_cover_contract_change" == true ]]; then + required_cover_contract=( + scripts/readme-cover.html + .github/assets/avatar-cover-dark-en.jpg + .github/assets/avatar-cover-light-en.jpg + .github/assets/avatar-cover-dark-zh-Hans.jpg + .github/assets/avatar-cover-light-zh-Hans.jpg + ) + for required_path in "${required_cover_contract[@]}"; do + if [[ -z "${cover_contract_paths[$required_path]+present}" ]]; then + index=-1 + break + fi + done + fi + if [[ "$index" -eq "${#changes[@]}" ]]; then if [[ "$has_docs_assets" == true && "$has_reusable_source" == false ]]; then path_class=docs-assets @@ -153,6 +179,24 @@ jobs: printf 'scope=%s\nbase=%s\nhead=%s\n' "$scope" "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT" printf 'Validation scope: %s\n' "$scope" + - name: Setup pnpm for documentation assets + if: steps.scope.outputs.scope == 'docs-assets' + uses: pnpm/action-setup@v4 + with: + version: 11.7.0 + + - name: Setup Node.js for documentation assets + if: steps.scope.outputs.scope == 'docs-assets' + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Install documentation asset validator dependencies + if: steps.scope.outputs.scope == 'docs-assets' + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Validate documentation, cover assets, and skill metadata if: steps.scope.outputs.scope == 'docs-assets' shell: bash @@ -163,6 +207,8 @@ jobs: set -euo pipefail git diff --check "$BASE_SHA" "$HEAD_SHA" + node scripts/validate-readme-cover-assets.mjs + python3 - <<'PY' import json import os diff --git a/__tests__/sdkCiWorkflow.spec.ts b/__tests__/sdkCiWorkflow.spec.ts index 4c0ff85..0e78267 100644 --- a/__tests__/sdkCiWorkflow.spec.ts +++ b/__tests__/sdkCiWorkflow.spec.ts @@ -1,11 +1,13 @@ import { execFileSync } from 'node:child_process' -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import jpeg from 'jpeg-js' import { describe, expect, it } from 'vitest' const workflowPath = new URL('../.github/workflows/sdk-ci.yml', import.meta.url) +const helperPath = new URL('../scripts/validate-readme-cover-assets.mjs', import.meta.url) const git = (cwd: string, args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim() const commit = (cwd: string, message: string) => { git(cwd, ['add', '-A']) @@ -26,13 +28,6 @@ const classifier = () => workflowBlock('Classify validation scope') const evidenceEligibility = () => workflowBlock('Guard reusable evidence trust boundary') -const docsValidator = () => { - const workflow = readFileSync(workflowPath, 'utf8') - const match = workflow.match(/ python3 - <<'PY'\n([\s\S]*?)\n PY/u) - expect(match?.[1]).toBeTruthy() - return match![1].replace(/^ /gmu, '') -} - describe('Avatar SDK CI workflow', () => { it('keeps the required identity behind validation and produces evidence in an isolated job', () => { const workflow = readFileSync(workflowPath, 'utf8') @@ -72,6 +67,9 @@ describe('Avatar SDK CI workflow', () => { writeFileSync(join(root, 'README.md'), 'base\n') writeFileSync(join(root, 'src', 'code.ts'), 'base\n') writeFileSync(join(root, 'scripts', 'readme-cover.html'), '\n') + writeFileSync(join(root, 'scripts', 'validate-readme-cover-assets.mjs'), 'base\n') + writeFileSync(join(root, 'package.json'), '{}\n') + writeFileSync(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') for (const cover of covers) writeFileSync(join(root, '.github', 'assets', cover), 'cover\n') writeFileSync(join(root, '.github', 'assets', 'bear-breed-profiles.png'), 'bear\n') writeFileSync(join(root, '.github', 'workflows', 'other.yml'), 'name: other\n') @@ -83,8 +81,14 @@ describe('Avatar SDK CI workflow', () => { return commit(root, name) } const coverHeads = covers.map(cover => make(cover, () => writeFileSync(join(root, '.github', 'assets', cover), `new ${cover}\n`))) + const coverContract = make('complete cover contract', () => { + writeFileSync(join(root, 'scripts', 'readme-cover.html'), ' changed\n') + for (const cover of covers) writeFileSync(join(root, '.github', 'assets', cover), `new ${cover}\n`) + }) const docs = make('docs', () => writeFileSync(join(root, 'README.md'), 'docs\n')) const source = make('source', () => writeFileSync(join(root, 'src', 'code.ts'), 'source\n')) + const packageJson = make('package manifest', () => writeFileSync(join(root, 'package.json'), '{"changed":true}\n')) + const lockfile = make('package lockfile', () => writeFileSync(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9\nchanged: true\n')) const coverHtml = make('cover html', () => writeFileSync(join(root, 'scripts', 'readme-cover.html'), '\n')) const bear = make('bear', () => writeFileSync(join(root, '.github', 'assets', 'bear-breed-profiles.png'), 'changed\n')) const otherGithub = make('other github', () => writeFileSync(join(root, '.github', 'workflows', 'other.yml'), 'name: changed\n')) @@ -112,14 +116,20 @@ describe('Avatar SDK CI workflow', () => { return readFileSync(output, 'utf8').match(/^scope=(.*)$/m)?.[1] } - for (const cover of coverHeads) expect(run('pull_request', cover, 'full')).toBe('docs-assets') + for (const cover of coverHeads) expect(run('pull_request', cover, 'full')).toBe('full') + expect(run('pull_request', coverContract, 'full')).toBe('docs-assets') expect(run('pull_request', docs, 'full')).toBe('docs-assets') expect(run('pull_request', source, 'full')).toBe('full') - for (const cover of coverHeads) expect(run('push', cover)).toBe('docs-assets') + expect(run('pull_request', packageJson, 'reused-pr-ci')).toBe('full') + expect(run('pull_request', lockfile, 'reused-pr-ci')).toBe('full') + for (const cover of coverHeads) expect(run('push', cover)).toBe('full') + expect(run('push', coverContract)).toBe('docs-assets') expect(run('push', source)).toBe('reused-pr-ci') + expect(run('push', packageJson)).toBe('full') + expect(run('push', lockfile)).toBe('full') expect(run('push', source, 'full')).toBe('full') expect(run('push', docs, 'full')).toBe('full') - expect(run('workflow_dispatch', coverHeads[0])).toBe('full') + expect(run('workflow_dispatch', coverContract)).toBe('full') for (const head of [coverHtml, bear, otherGithub, unknown, deletion, rename, executable, symlink, mixed]) { expect(run('push', head)).toBe('full') } @@ -133,6 +143,9 @@ describe('Avatar SDK CI workflow', () => { mkdirSync(join(root, 'src')) writeFileSync(join(root, '.github', 'workflows', 'sdk-ci.yml'), 'base\n') writeFileSync(join(root, 'scripts', 'verify-pr-ci-evidence.mjs'), 'base\n') + writeFileSync(join(root, 'scripts', 'validate-readme-cover-assets.mjs'), 'base\n') + writeFileSync(join(root, 'package.json'), '{}\n') + writeFileSync(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n') writeFileSync(join(root, 'src', 'code.ts'), 'base\n') const base = commit(root, 'base') const make = (path: string) => { @@ -143,6 +156,9 @@ describe('Avatar SDK CI workflow', () => { const source = make('src/code.ts') const workflow = make('.github/workflows/sdk-ci.yml') const helper = make('scripts/verify-pr-ci-evidence.mjs') + const validator = make('scripts/validate-readme-cover-assets.mjs') + const packageJson = make('package.json') + const lockfile = make('pnpm-lock.yaml') const run = (head: string) => { const output = join(root, `eligibility-${head.slice(0, 8)}`) execFileSync('bash', ['-c', evidenceEligibility()], { @@ -155,31 +171,105 @@ describe('Avatar SDK CI workflow', () => { expect(run(source)).toBe('true') expect(run(workflow)).toBe('false') expect(run(helper)).toBe('false') + expect(run(validator)).toBe('false') + expect(run(packageJson)).toBe('false') + expect(run(lockfile)).toBe('false') }) - it('validates all four real README cover links during a cover-only update', () => { + it('runs the real cover validator against structural and complete-decode fixtures', () => { const root = mkdtempSync(join(tmpdir(), 'avatar-cover-validator-')) git(root, ['init', '-b', 'main']) + mkdirSync(join(root, 'scripts'), { recursive: true }) mkdirSync(join(root, '.github', 'assets'), { recursive: true }) - const en = ['.github/assets/avatar-cover-dark-en.jpg', '.github/assets/avatar-cover-light-en.jpg'] - const zh = ['.github/assets/avatar-cover-dark-zh-Hans.jpg', '.github/assets/avatar-cover-light-zh-Hans.jpg'] - writeFileSync(join(root, 'README.md'), en.map(path => ``).join('\n')) - writeFileSync(join(root, 'README.zh-Hans.md'), zh.map(path => ``).join('\n')) - for (const image of [...en, ...zh]) writeFileSync(join(root, image), Buffer.from([0xff, 0xd8, 0xff, 0xd9])) + symlinkSync(join(process.cwd(), 'node_modules'), join(root, 'node_modules'), 'dir') + for (const path of ['README.md', 'README.zh-Hans.md', 'scripts/readme-cover.html']) cpSync(new URL(`../${path}`, import.meta.url), join(root, path)) + cpSync(helperPath, join(root, 'scripts', 'validate-readme-cover-assets.mjs')) + const covers = ['avatar-cover-dark-en.jpg', 'avatar-cover-light-en.jpg', 'avatar-cover-dark-zh-Hans.jpg', 'avatar-cover-light-zh-Hans.jpg'] + for (const cover of covers) cpSync(new URL(`../.github/assets/${cover}`, import.meta.url), join(root, '.github', 'assets', cover)) + for (const [, source] of readFileSync(new URL('../scripts/readme-cover.html', import.meta.url), 'utf8').matchAll(/]*\bsrc="([^"]+)"[^>]*>/gu)) { + const destination = join(root, source.slice(3)) + mkdirSync(dirname(destination), { recursive: true }) + cpSync(new URL(`../scripts/${source}`, import.meta.url), destination) + } const base = commit(root, 'base') - writeFileSync(join(root, en[0]), Buffer.from([0xff, 0xd8, 0xff, 0x00, 0xff, 0xd9])) - const valid = commit(root, 'cover update') - const execute = (head: string) => execFileSync('python3', ['-c', docsValidator()], { - cwd: root, - env: { ...process.env, BASE_SHA: base, HEAD_SHA: head }, - stdio: 'pipe' - }) + const execute = (head: string) => { + git(root, ['checkout', '--detach', head]) + expect(git(root, ['rev-parse', 'HEAD'])).toBe(head) + return execFileSync('node', ['scripts/validate-readme-cover-assets.mjs'], { + cwd: root, + env: { ...process.env, BASE_SHA: base, HEAD_SHA: head }, + stdio: 'pipe' + }) + } + writeFileSync(join(root, 'README.md'), `${readFileSync(join(root, 'README.md'), 'utf8')}\n`) + const valid = commit(root, 'README-only update') expect(() => execute(valid)).not.toThrow() - writeFileSync(join(root, 'README.md'), `\n${en[1]}`) - writeFileSync(join(root, en[1]), Buffer.from([0xff, 0xd8, 0xff, 0x01, 0xff, 0xd9])) - const missingLink = commit(root, 'missing link') - expect(() => execute(missingLink)).toThrow() + const make = (name: string, mutate: () => void) => { + git(root, ['checkout', '--detach', base]) + mutate() + return commit(root, name) + } + const completeContract = () => { + for (const cover of covers) { + const file = join(root, '.github', 'assets', cover) + const data = readFileSync(file) + writeFileSync(file, Buffer.concat([data.subarray(0, 2), Buffer.from([0xff, 0xe0, 0, 2]), data.subarray(2)])) + } + } + const generatorInvalid = (name: string, mutation: (source: string) => string) => make(name, () => { + const file = join(root, 'scripts', 'readme-cover.html') + writeFileSync(file, mutation(readFileSync(file, 'utf8'))) + completeContract() + }) + const generatorScriptAttribute = generatorInvalid('script attribute', source => source.replace('')) + const multipleStyle = generatorInvalid('multiple style', source => source.replace('', '')) + const unsafeCss = generatorInvalid('unsafe css', source => source.replace('