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('