import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; const workflowPath = new URL('../.github/workflows/skill-release.yml', import.meta.url); const ciWorkflowPath = new URL('../.github/workflows/ci.yml', import.meta.url); const workflow = await readFile(workflowPath, 'utf8'); const ciWorkflow = await readFile(ciWorkflowPath, 'utf8'); assert.match( workflow, /pull_request:[\s\S]*paths:[\s\S]*- 'skills\/\*\*'/, 'Skill release workflow must run when any skill package file changes', ); assert.match( workflow, /pull_request:[\s\S]*paths:[\s\S]*- '\.github\/workflows\/skill-release\.yml'[\s\S]*- 'scripts\/ci\/\*\*'/, 'Skill release workflow must also run when the release pipeline itself changes', ); assert.ok( ciWorkflow.includes(` - name: Skill Release Tooling Tests run: | set -euo pipefail for test_file in scripts/test-skill-*.mjs; do node "$test_file" done`), 'CI must run every scripts/test-skill-*.mjs file so new skill release tests are not orphaned', ); assert.match( workflow, /git diff --name-only "\$\{BASE_SHA\}\.\.\.\$\{HEAD_SHA\}" --[\s\S]*'skills\/\*\/\*\*'[\s\S]*':\(exclude\)skills\/\*\/test\/\*\*'[\s\S]*':\(exclude\)skills\/\*\/tests\/\*\*'/, 'Skill release validation must ignore test-only skill changes while inspecting release-relevant skill files', ); assert.doesNotMatch( workflow, /No version bump detected for \$\{skill_dir\}; skipping\./, 'Changed skill directories without a version bump must not be skipped without release-tag validation', ); assert.match( workflow, /skill_release_name="\$\(basename "\$\{skill_dir\}"\)"/, 'Skill release validation must derive the release tag prefix from the skill package directory', ); assert.match( workflow, /release_tag="\$\{skill_release_name\}-v\$\{head_json_version\}"/, 'Skill release validation must use the skill package directory name for release tag checks', ); assert.doesNotMatch( workflow, /release_tag="\$\{head_skill_name\}-v\$\{head_json_version\}"/, 'Skill release validation must not use skill.json name for release tag checks because release tags resolve to skill directories', ); assert.match( workflow, /git show-ref --verify --quiet "refs\/tags\/\$\{release_tag\}"/, 'Skill release validation must check whether the current skill version has already been tagged', ); assert.match( workflow, /No version bump detected for \$\{skill_dir\}, but release tag \$\{release_tag\} does not exist; treating \$\{head_json_version\} as unreleased\./, 'Skill release validation must allow edits to an unchanged version when that release tag does not exist yet', ); assert.match( workflow, /::error file=\$\{skill_dir\}::Changed skill package has no version bump and release tag \$\{release_tag\} already exists\./, 'Skill release validation must still fail unchanged versions after their release tag exists', ); assert.match( workflow, /Install SkillSpector/, 'Skill release workflow must install SkillSpector before publishing release evidence', ); assert.match( workflow, /Generate SkillSpector report/, 'Skill release workflow must generate a SkillSpector report for each released skill', ); assert.match( workflow, /### SkillSpector Security Report[\s\S]*\[skillspector-report\.md\]\(https:\/\/github\.com\/\$\{process\.env\.REPO\}\/releases\/download\/\$\{process\.env\.TAG\}\/skillspector-report\.md\)/, 'GitHub release notes must include a direct SkillSpector report link', ); assert.match( workflow, /readFileSync\("release-assets\/skillspector-report\.md", "utf8"\)/, 'GitHub release notes must load the generated SkillSpector report content into the release body file', ); assert.match( workflow, /body_path: \$\{\{ runner\.temp \}\}\/skill-release-body\.md/, 'GitHub release creation must use body_path for the generated release body file', ); assert.doesNotMatch( workflow, /SKILLSPECTOR_REPORT_EOF|\$\{\{ steps\.skillspector_report\.outputs\.body \}\}|cat release-assets\/skillspector-report\.md[\s\S]*>> "\$GITHUB_OUTPUT"/, 'SkillSpector report content must not be sent through GitHub Actions step outputs', ); assert.match( workflow, /generate_skillspector_report "\$\{inner_dir\}" "\$\{out_assets\}\/skillspector-report\.md"/, 'PR dry-run SkillSpector scan must target the staged release payload, not the source skill directory', ); assert.doesNotMatch( workflow, /generate_skillspector_report "\$\{skill_dir\}" "\$\{out_assets\}\/skillspector-report\.md"/, 'PR dry-run SkillSpector scan must not include source-only test directories', ); assert.match( workflow, /generate_skillspector_report "\$INNER_DIR" "release-assets\/skillspector-report\.md"/, 'Tag release SkillSpector scan must target the staged release payload, not the source skill directory', ); assert.doesNotMatch( workflow, /generate_skillspector_report "\$SKILL_PATH" "release-assets\/skillspector-report\.md"/, 'Tag release SkillSpector scan must not include source-only test directories', ); assert.match( workflow, /Generate release trust packet/, 'Skill release workflow must generate skill cards, permission summaries, and npx install instructions', ); for (const artifact of ['skill-card.md', 'permissions.json', 'install.md', 'skillspector-report.md']) { assert.match( workflow, new RegExp(`release-assets/${artifact.replace('.', '\\.')}`), `Skill release workflow must publish ${artifact} in release assets`, ); } const escapeRegExp = (literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); for (const artifact of ['skill-card.md', 'permissions.json', 'install.md', 'skillspector-report.md']) { assert.match( workflow, new RegExp( String.raw`if ! add_release_asset_checksum "\$\{out_assets\}" "${escapeRegExp(artifact)}"; then` + String.raw`[\s\S]*?failures=\$\(\(failures \+ 1\)\)[\s\S]*?continue[\s\S]*?fi`, ), `PR dry-run validation must aggregate and continue when ${artifact} cannot be checksummed`, ); } assert.match( workflow, /add_release_asset_checksum "skill-card\.md"/, 'Skill card must be included in the signed checksums manifest', ); assert.match( workflow, /add_release_asset_checksum "permissions\.json"/, 'Permissions summary must be included in the signed checksums manifest', ); assert.match( workflow, /add_release_asset_checksum "install\.md"/, 'npx install/update instructions must be included in the signed checksums manifest', ); assert.match( workflow, /add_release_asset_checksum "skillspector-report\.md"/, 'SkillSpector report must be included in the signed checksums manifest', ); assert.match( workflow, /Simulate tag release build/, 'Skill release workflow must simulate a tag release build during PR validation', ); assert.match( workflow, /simulate_skill_tag_release\.mjs/, 'Skill release workflow must call the tag release simulation script', ); assert.ok( workflow.includes('simulated_version | test("^[0-9]+\\\\.[0-9]+\\\\.[0-9]+(-[a-zA-Z0-9]+)?$")'), 'Skill release workflow must accept every prerelease version format that release-skill.sh accepts', ); assert.match( workflow, /clawhub_slug: \$\{\{ steps\.publishable\.outputs\.clawhub_slug \}\}/, 'Skill release workflow must expose the resolved ClawHub slug from release-tag outputs', ); assert.match( workflow, /CLAWHUB_SLUG=\$\(node scripts\/ci\/resolve_clawhub_slug\.mjs "\$SKILL_PATH"\)/, 'Skill release workflow must resolve the ClawHub slug from the skill package path', ); assert.match( workflow, /cp scripts\/ci\/resolve_clawhub_slug\.mjs "\$RUNNER_TEMP\/resolve_clawhub_slug\.mjs"/, 'Manual ClawHub republish must preserve the current slug helper before checking out an older release tag', ); assert.match( workflow, /CLAWHUB_SLUG=\$\(node "\$RUNNER_TEMP\/resolve_clawhub_slug\.mjs" "\$SKILL_PATH"\)/, 'Manual ClawHub republish must resolve slugs with the preserved helper against the checked-out tag metadata', ); assert.match( workflow, /npx clawhub@latest install \$\{CLAWHUB_SLUG\}/, 'GitHub release quick install instructions must use the resolved ClawHub slug', ); assert.match( workflow, /clawhub inspect "\$CLAWHUB_SLUG" --version "\$VERSION" --json/, 'Duplicate ClawHub version guard must inspect the resolved ClawHub slug', ); assert.match( workflow, /--slug "\$CLAWHUB_SLUG"/, 'ClawHub publish must use the resolved ClawHub slug', ); assert.doesNotMatch( workflow, /clawhub inspect "\$SKILL_NAME" --version "\$VERSION" --json/, 'Duplicate ClawHub version guard must not inspect the raw skill package name', ); assert.doesNotMatch( workflow, /--slug "\$SKILL_NAME"/, 'ClawHub publish must not use the raw skill package name as the ClawHub slug', );