ui-ux-pro-max-skill/.github/workflows/check-asset-sync.yml
notbucki 15de38fb70
fix(design): make workflows self-contained, add bundled-skill contract (#498)
* fix(design): make workflows self-contained, add bundled-skill contract (#474 finding 2)

The design skill's banner and social-photos workflows named skills the plugin
does not ship (frontend-design, ai-artist, ai-multimodal, chrome-devtools,
project-management, assets-organizing) and claudekit command namespaces
(/ckm:brand, /ck:ui-ux-pro-max), so those steps fail or force the agent to
improvise under a plugin or CLI install. Rewrite them after the pattern of
#473: bundled skills stay referenced by name, optional tooling becomes an
optional runtime capability, unbundled steps are implemented inline or
dropped. The same references sat in references/social-photos-design.md, so
the fix covers the skill directory, not just SKILL.md; the CLI copy is synced.

check-asset-sync.yml gains a bundled-skill contract over both skill trees:
no /ck: or /ckm: namespace, every "`name` skill" reference must be a
directory under .claude/skills, and none of the claudekit-only names above
in any spelling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(design): address review - list-aware skill grep, case-insensitive denylist, banner intro, report naming

- contract grep 2 walks comma/and/or lists so every member is checked, not
  only the last one, and accepts the sub-skill and bold forms; grep 3 matches
  case-insensitively; the step comment states what is not covered
- banner intro no longer claims "no sibling skills" while step 2 names the
  bundled ui-ux-pro-max skill
- social-photos report name stated inline instead of "naming pattern from
  session hooks" (a claudekit convention nothing in the bundle provides);
  "persistent" dropped from the task-list step; device-scale note scoped to
  tools that support it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(ci): match wrapped skill lists - read each file as one record in the bundled-skill contract

A Markdown list wrapped over two lines ("`playwright-mcp`,\n`brand`, and
`ui-ux-pro-max` skills") passed the line-oriented allowlist grep. Read the
file as one record (-z, separators as \s+) so wrapped lists are still lists;
a hit now names the file and the name instead of the line. grep's own exit
status is taken inside the substitution so an unreadable file still fails the
step under plain bash -e, pipefail or not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 16:29:29 +07:00

157 lines
11 KiB
YAML

name: Check asset sync
on:
pull_request:
paths:
- "src/ui-ux-pro-max/**"
- "cli/assets/**"
- ".claude/skills/**"
- "cli/scripts/sync-assets.mjs"
- "cli/package.json"
- "scripts/evaluate-relevance.py"
- "scripts/relevance_metrics.py"
- ".github/workflows/check-asset-sync.yml"
push:
branches: [main]
paths:
- "src/ui-ux-pro-max/**"
- "cli/assets/**"
- "cli/package.json"
- ".claude/skills/**"
- "cli/scripts/sync-assets.mjs"
- ".github/workflows/check-asset-sync.yml"
jobs:
check-assets:
name: cli/assets and .claude/skills/ui-ux-pro-max must match src/ui-ux-pro-max
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
# check:assets runs `node scripts/sync-assets.mjs --check`, which uses only
# node builtins (no npm install needed) and normalizes CRLF/LF before
# hashing, so it compares content rather than line endings. It checks
# both cli/assets/ AND .claude/skills/ui-ux-pro-max/{data,scripts} --
# the latter is what Claude Code actually loads when this repo is
# installed as a plugin, and previously had no sync check at all.
- name: Check assets are in sync with source of truth
run: npm --prefix cli run check:assets
# Path contract (#474): skill instructions and scripts reach their scripts
# via skill-relative paths ("scripts/<file>" for the skill's own, "../<skill>/scripts/<file>"
# for a sibling sub-skill) so they resolve in every install context: marketplace/
# plugin cache, project-level CLI install, CLI --global install, manual copy.
# Home-rooted ("~/.claude/skills/<skill>"), project-rooted (".claude/skills/<skill>")
# and variable-rooted ("$HOME/...", "${PWD}/...") forms each work in only one of them.
# Every file under both skill trees is checked, not just SKILL.md - the first
# version of this step looked only at SKILL.md and missed 27 home-rooted paths
# one directory down in references/. The one allowed absolute form is
# "${CLAUDE_PLUGIN_ROOT}/.claude/skills/ui-ux-pro-max/..." (braced or bare variable,
# directly followed by "/"): the core skill's SKILL.md is hand-authored for the
# plugin install only and that variable anchors it there. The same variable into a
# sub-skill is flagged, because sub-skills are also installed by the CLI where it is
# unset - and a third check pins the token itself to that one file (plus the checker
# that names it), so a sub-skill cannot borrow the core form either: sub-skills ship
# through the CLI too, where the variable does not exist. Both patterns require a path INTO a named skill ("skills/<name>"), so a bare
# mention of the directory in prose or a code comment ("~/.claude/skills/, or ...")
# is not a hit - naming a skill after "skills/" in prose is.
# LC_ALL=C so that only NUL-containing files count as binary and an offending line
# with a stray non-UTF-8 byte is printed instead of suppressed as improperly encoded
# (the verdict is the same in both locales; the diagnostic is not); -I then skips
# .claude/skills/ui-styling/scripts/.coverage, a tracked SQLite database whose
# recorded absolute paths contain "/.claude/skills/ui-styling/".
# Not covered: backslash-separated Windows spellings and the platform-root-relative
# "skills/<skill>/..." form - the docs are bash-fenced and skill-relative, so neither
# appears; the positive side (every documented invocation names a file that ships)
# is src/ui-ux-pro-max/scripts/tests/test_skill_script_paths.py.
# grep exit codes: 0 = hits (violation), 1 = clean, 2 = error - only 1 passes, so an
# unreadable file can never turn into a green run (a missing tree is caught above).
- name: Path contract - no install-specific skill paths
run: |
for d in .claude/skills cli/assets/skills; do
[ -d "$d" ] || { echo "::error::$d is missing - the path contract has nothing to scan"; exit 1; }
done
status=0
rc=0; LC_ALL=C grep -rnIP '~/\.claude/skills/[A-Za-z0-9_-]+\b' .claude/skills cli/assets/skills || rc=$?
if [ "$rc" -ne 1 ]; then
echo "::error::home-rooted skill paths (~/.claude/skills/<skill>) only resolve for one install layout - use skill-relative paths (see #474); grep rc=$rc"
status=1
fi
rc=0; LC_ALL=C grep -rnIP '(?<![/\w])\.claude/skills/[A-Za-z0-9_-]+\b|(?<!~)(?<!\$CLAUDE_PLUGIN_ROOT)(?<!\$\{CLAUDE_PLUGIN_ROOT\})/\.claude/skills/[A-Za-z0-9_-]+\b|\$\{?CLAUDE_PLUGIN_ROOT\}?/\.claude/skills/(?!ui-ux-pro-max\b)[A-Za-z0-9_-]+\b' .claude/skills cli/assets/skills || rc=$?
if [ "$rc" -ne 1 ]; then
echo "::error::project- or variable-rooted skill paths (.claude/skills/<skill>, \$HOME/..., \${CLAUDE_PLUGIN_ROOT}/... outside the core skill) only resolve for one install layout - use skill-relative paths (see #474); grep rc=$rc"
status=1
fi
rc=0; found=$(LC_ALL=C grep -rlIF 'CLAUDE_PLUGIN_ROOT' .claude/skills cli/assets/skills) || rc=$?
if [ "$rc" -eq 2 ]; then echo "::error::grep failed while scanning for CLAUDE_PLUGIN_ROOT (rc=2)"; status=1; fi
offenders=$(printf '%s\n' "$found" | grep -vxF -e '.claude/skills/ui-ux-pro-max/SKILL.md' -e '.claude/skills/ui-ux-pro-max/scripts/tests/test_skill_script_paths.py' | grep -v '^$' || true)
if [ -n "$offenders" ]; then
printf '%s\n' "$offenders"
echo "::error::CLAUDE_PLUGIN_ROOT is only defined under a plugin install; only the plugin-only core SKILL.md may use it - sub-skills ship through the CLI too (see #474)"
status=1
fi
if [ "$status" -eq 0 ]; then echo "OK: all skill paths are skill-relative"; fi
exit "$status"
# Bundled-skill contract (#474 finding 2): the skill directories under .claude/skills are
# everything a plugin or CLI install ships, so a workflow step that names a skill outside
# that set ("frontend-design", "chrome-devtools", ...) or a claudekit command namespace
# ("/ckm:brand", "/ck:frontend-design") fails silently or leaves the agent improvising under
# either install. #473 made banner-design self-contained; this step keeps every shipped
# skill that way. Three greps over every shipped Markdown file in both trees:
# 1. no claudekit command namespace - "/ck:" or "/ckm:" not preceded by a word character
# or "/", so a URL or file path containing the letters is not a hit;
# 2. every backticked name followed by "skill", "skills" or "sub-skill" (the form these docs
# use to name a skill) must be a directory under .claude/skills - an allowlist, so a new
# unbundled name fails without editing this file. The lookahead walks a list ("`a`, `b`,
# and `c` skills", separators ", ", " and ", " or ", "/", " -> ") so every member is
# checked, not only the last one, and tolerates "**" around the name. The file is read as
# one record (-z, separators as \s+) so a list wrapped over several lines is still a
# list; a hit therefore names the file and the name, not the line;
# 3. none of the claudekit-only skill names the shipped docs used to reference, matched
# case-insensitively in their hyphenated spelling - "Related Skills: frontend-design, ..."
# carries neither backticks nor a "skill" suffix, so 2. cannot see it. "project-management"
# is also ordinary English: a hit in prose is a reword, not a workflow step.
# Not covered: a spaced or underscored spelling ("frontend design"); a bare name with no
# backticks and no "skill" suffix that is not on the list in 3.; mentions inside scripts
# (brand/scripts/extract-colors.cjs names ai-multimodal three times as a hint, one of them
# "if installed" - not a workflow step); cli/assets/templates, the CLI's rendered core skill,
# which neither this step nor the path contract scans (clean at the time of writing); and a
# bundled-set change - a skill directory added or removed under .claude/skills moves the
# allowlist with it, which is the intent.
# grep exit codes as above: for 1. and 3. only 1 (no hits) passes; for 2. hits are fine as
# long as every name is bundled, and 2 (error) never passes.
- name: Bundled-skill contract - no unbundled skill or claudekit command references
run: |
[ -d .claude/skills ] || { echo "::error::.claude/skills is missing - the bundled set cannot be derived"; exit 1; }
bundled=$(find .claude/skills -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | LC_ALL=C sort)
[ -n "$bundled" ] || { echo "::error::.claude/skills has no skill directories - the bundled set cannot be derived"; exit 1; }
status=0
rc=0; LC_ALL=C grep -rnIP --include='*.md' '(?<![\w/])/ckm?:' .claude/skills cli/assets/skills || rc=$?
if [ "$rc" -ne 1 ]; then
echo "::error::claudekit command namespaces (/ck:, /ckm:) are not shipped by this plugin - name the bundled skill instead (see #474 finding 2); grep rc=$rc"
status=1
fi
rc=0; hits=$(LC_ALL=C grep -rzoIP --include='*.md' '`[a-z0-9-]+`(?:\*\*)?(?=(?:(?:,\s+|\s+and\s+|,\s+and\s+|\s+or\s+|,\s+or\s+|/|\s+→\s+|\s+->\s+)(?:\*\*)?`[a-z0-9-]+`(?:\*\*)?)*\s+(?:sub-)?[Ss]kills?\b)' .claude/skills cli/assets/skills | tr '\0' '\n'; exit "${PIPESTATUS[0]}") || rc=$?
if [ "$rc" -eq 2 ]; then echo "::error::grep failed while scanning for skill references (rc=2)"; status=1; fi
unbundled=0
while IFS= read -r hit; do
[ -n "$hit" ] || continue
name=$(printf '%s\n' "$hit" | sed 's/.*`\([^`]*\)`.*/\1/')
if ! printf '%s\n' "$bundled" | grep -qxF -- "$name"; then
printf '%s\n' "$hit"
unbundled=1
fi
done <<< "$hits"
if [ "$unbundled" -ne 0 ]; then
echo "::error::skill references outside the bundled set ($(printf '%s' "$bundled" | tr '\n' ' ')) fail under a plugin or CLI install - implement the step inline, name a bundled skill, or drop the step (see #474 finding 2)"
status=1
fi
rc=0; LC_ALL=C grep -rniIP --include='*.md' '\b(frontend-design|ai-artist|ai-multimodal|chrome-devtools|assets-organizing|project-management)\b' .claude/skills cli/assets/skills || rc=$?
if [ "$rc" -ne 1 ]; then
echo "::error::claudekit-only skill names are not shipped by this plugin - implement the step inline, name a bundled skill, or drop the step (see #474 finding 2); grep rc=$rc"
status=1
fi
if [ "$status" -eq 0 ]; then echo "OK: shipped skill docs reference only bundled skills"; fi
exit "$status"