diff --git a/.github/workflows/mattpocock-skills-reviewer.lock.yml b/.github/workflows/mattpocock-skills-reviewer.lock.yml index 2fb62fe5317..2280323fb07 100644 --- a/.github/workflows/mattpocock-skills-reviewer.lock.yml +++ b/.github/workflows/mattpocock-skills-reviewer.lock.yml @@ -1,5 +1,5 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8d55df2fd4892893c54938eeb7b5003444a04e43648ebc15b82637bcaa3675d1","body_hash":"62ce0bd5c9851424f73f018934c48ae0ca6493b0bc7415fdc5564e808707829a","strict":true,"agent_id":"copilot","agent_model":"claude-sonnet-4.6","engine_versions":{"copilot":"1.0.65"}} -# gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.15"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.15"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.15"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.15"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.32","digest":"sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.32@sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"26e8a754482b7347113adc6d77b27f62e3dee49e84d972a2e35662fd319720b5","body_hash":"62ce0bd5c9851424f73f018934c48ae0ca6493b0bc7415fdc5564e808707829a","strict":true,"agent_id":"copilot","agent_model":"claude-sonnet-4.6","engine_versions":{"copilot":"1.0.65"}} +# gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.13","digest":"sha256:691a06b64961b5b35aac117eaace202fa721e91da19d1d2e22dcdd6663cd571b","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.13@sha256:691a06b64961b5b35aac117eaace202fa721e91da19d1d2e22dcdd6663cd571b"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.13","digest":"sha256:c57febf4aeeefbb4fd96f5b12c07f4279ca55edca6a700032debf9dd0787286e","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.13@sha256:c57febf4aeeefbb4fd96f5b12c07f4279ca55edca6a700032debf9dd0787286e"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.13","digest":"sha256:79dfd3a5d139bd1956ba6d7d1782b831a07175cf5afa29c45cb20bb0140f23c5","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.13@sha256:79dfd3a5d139bd1956ba6d7d1782b831a07175cf5afa29c45cb20bb0140f23c5"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.13","digest":"sha256:700b1b5a73098373b04fb684f291e95d9be0124ab559717b04f27acaf8b41bed","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.13@sha256:700b1b5a73098373b04fb684f291e95d9be0124ab559717b04f27acaf8b41bed"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.32","digest":"sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.32@sha256:63e46b56dfd70895a701b6fc6dd0189e11e2d875f327f1781e81b31848735477"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ @@ -163,6 +163,7 @@ jobs: GH_AW_INFO_FIREWALL_TYPE: "squid" GH_AW_INFO_FRONTMATTER_EMOJI: "🔍" GH_AW_COMPILED_STRICT: "true" + GH_AW_INFO_SKILLS: '["mattpocock/skills/diagnosing-bugs@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/tdd@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/improve-codebase-architecture@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/grill-with-docs@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/to-prd@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/codebase-design@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/domain-modeling@801dca688564c529fa84f247f64472520d9ebe28"]' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | @@ -286,6 +287,103 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/add_workflow_run_comment.cjs'); await main(); + - name: Upgrade gh CLI for frontmatter skills + run: | + set -euo pipefail + bash "${RUNNER_TEMP}/gh-aw/actions/install_gh_cli.sh" + GH_VERSION=$(gh --version | awk 'NR==1 {print $3}') + REQUIRED="2.90.0" + echo "gh version: ${GH_VERSION}" + if ! printf '%s\n%s\n' "$REQUIRED" "$GH_VERSION" | sort -V -C; then + echo "::error::gh ${GH_VERSION} is older than required ${REQUIRED} (gh skill support requires v2.90+)" + exit 1 + fi + - name: Install frontmatter skills + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_SKILL_DIR: ".github/skills" + GH_AW_SKILLS_SUMMARY: '["mattpocock/skills/diagnosing-bugs@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/tdd@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/improve-codebase-architecture@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/grill-with-docs@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/to-prd@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/codebase-design@801dca688564c529fa84f247f64472520d9ebe28","mattpocock/skills/domain-modeling@801dca688564c529fa84f247f64472520d9ebe28"]' + GH_AW_SKILL_SPEC_0: "mattpocock/skills/diagnosing-bugs@801dca688564c529fa84f247f64472520d9ebe28" + GH_AW_SKILL_SPEC_1: "mattpocock/skills/tdd@801dca688564c529fa84f247f64472520d9ebe28" + GH_AW_SKILL_SPEC_2: "mattpocock/skills/improve-codebase-architecture@801dca688564c529fa84f247f64472520d9ebe28" + GH_AW_SKILL_SPEC_3: "mattpocock/skills/grill-with-docs@801dca688564c529fa84f247f64472520d9ebe28" + GH_AW_SKILL_SPEC_4: "mattpocock/skills/to-prd@801dca688564c529fa84f247f64472520d9ebe28" + GH_AW_SKILL_SPEC_5: "mattpocock/skills/codebase-design@801dca688564c529fa84f247f64472520d9ebe28" + GH_AW_SKILL_SPEC_6: "mattpocock/skills/domain-modeling@801dca688564c529fa84f247f64472520d9ebe28" + run: | + set -euo pipefail + SKILLS_DST="/tmp/gh-aw/${GH_AW_SKILL_DIR}" + mkdir -p "${SKILLS_DST}" + echo "Installing frontmatter skills to ${SKILLS_DST}" + echo "Existing skills at destination may be replaced (--force) to ensure pinned refs are up to date" + skill_spec="${GH_AW_SKILL_SPEC_0}" + echo "Installing skill reference: ${skill_spec}" + skill_base="${skill_spec%@*}" + install_args=() + if [[ "${skill_base}" == */* && "${skill_base}" != */*/* ]]; then + install_args+=(--all) + fi + gh skill install "${skill_spec}" "${install_args[@]}" --dir "${SKILLS_DST}" --force + skill_spec="${GH_AW_SKILL_SPEC_1}" + echo "Installing skill reference: ${skill_spec}" + skill_base="${skill_spec%@*}" + install_args=() + if [[ "${skill_base}" == */* && "${skill_base}" != */*/* ]]; then + install_args+=(--all) + fi + gh skill install "${skill_spec}" "${install_args[@]}" --dir "${SKILLS_DST}" --force + skill_spec="${GH_AW_SKILL_SPEC_2}" + echo "Installing skill reference: ${skill_spec}" + skill_base="${skill_spec%@*}" + install_args=() + if [[ "${skill_base}" == */* && "${skill_base}" != */*/* ]]; then + install_args+=(--all) + fi + gh skill install "${skill_spec}" "${install_args[@]}" --dir "${SKILLS_DST}" --force + skill_spec="${GH_AW_SKILL_SPEC_3}" + echo "Installing skill reference: ${skill_spec}" + skill_base="${skill_spec%@*}" + install_args=() + if [[ "${skill_base}" == */* && "${skill_base}" != */*/* ]]; then + install_args+=(--all) + fi + gh skill install "${skill_spec}" "${install_args[@]}" --dir "${SKILLS_DST}" --force + skill_spec="${GH_AW_SKILL_SPEC_4}" + echo "Installing skill reference: ${skill_spec}" + skill_base="${skill_spec%@*}" + install_args=() + if [[ "${skill_base}" == */* && "${skill_base}" != */*/* ]]; then + install_args+=(--all) + fi + gh skill install "${skill_spec}" "${install_args[@]}" --dir "${SKILLS_DST}" --force + skill_spec="${GH_AW_SKILL_SPEC_5}" + echo "Installing skill reference: ${skill_spec}" + skill_base="${skill_spec%@*}" + install_args=() + if [[ "${skill_base}" == */* && "${skill_base}" != */*/* ]]; then + install_args+=(--all) + fi + gh skill install "${skill_spec}" "${install_args[@]}" --dir "${SKILLS_DST}" --force + skill_spec="${GH_AW_SKILL_SPEC_6}" + echo "Installing skill reference: ${skill_spec}" + skill_base="${skill_spec%@*}" + install_args=() + if [[ "${skill_base}" == */* && "${skill_base}" != */*/* ]]; then + install_args+=(--all) + fi + gh skill install "${skill_spec}" "${install_args[@]}" --dir "${SKILLS_DST}" --force + SKILL_COUNT=$(find "${SKILLS_DST}" -name "SKILL.md" | wc -l | tr -d '[:space:]') + echo "Installed ${SKILL_COUNT} skill file(s)" + core_summary_path="${GITHUB_STEP_SUMMARY:-}" + if [ -n "${core_summary_path}" ]; then + { + echo "### Frontmatter skills installed" + echo "" + echo "- Engine skill directory: \`${GH_AW_SKILL_DIR}\`" + echo "- Requested references: \`${GH_AW_SKILLS_SUMMARY}\`" + echo "- Installed SKILL.md files: ${SKILL_COUNT}" + } >> "${core_summary_path}" + fi - name: Log runtime features if: ${{ contains(toJSON(vars), '"GH_AW_RUNTIME_FEATURES":') }} run: bash "${RUNNER_TEMP}/gh-aw/actions/log_runtime_features_summary.sh" @@ -585,12 +683,6 @@ jobs: env: GH_AW_SKILL_DIR: ".github/skills" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh" - - name: Upgrade gh CLI - run: "bash \"${RUNNER_TEMP}/gh-aw/actions/install_gh_cli.sh\"\nGH_VERSION=$(gh --version | head -1 | grep -oP '\\d+\\.\\d+\\.\\d+')\necho \"gh version: ${GH_VERSION}\"\nREQUIRED=\"2.90.0\"\nif ! printf '%s\\n%s\\n' \"$REQUIRED\" \"$GH_VERSION\" | sort -V -C; then\n echo \"::error::gh ${GH_VERSION} is older than required ${REQUIRED} (gh skill support requires v2.90+)\"\n exit 1\nfi\n" - - env: - GH_TOKEN: ${{ github.token }} - name: Install Matt Pocock skills - run: "set -euo pipefail\nSKILLS_DST=\"${RUNNER_TEMP}/gh-aw/mattpocock-skills\"\nmkdir -p \"${SKILLS_DST}\"\n# Install only the skills referenced in this workflow's prompt, rather than\n# all published skills, to reduce install time and network overhead.\nfor skill in diagnosing-bugs tdd improve-codebase-architecture grill-with-docs to-prd codebase-design domain-modeling; do\n gh skill install mattpocock/skills \"${skill}\" --dir \"${SKILLS_DST}\" --force\ndone\nSKILL_COUNT=$(find \"${SKILLS_DST}\" -name \"SKILL.md\" | wc -l)\necho \"Installed ${SKILL_COUNT} skill(s):\"\nfind \"${SKILLS_DST}\" -name \"SKILL.md\" | head -20\nif [ \"${SKILL_COUNT}\" -eq 0 ]; then\n echo \"::error::No SKILL.md files found after installing mattpocock/skills\"\n exit 1\nfi\n" - env: EXPR_GITHUB_REPOSITORY: ${{ github.repository }} GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/mattpocock-skills-reviewer.md b/.github/workflows/mattpocock-skills-reviewer.md index 4e022c5b4ba..69f264c0f08 100644 --- a/.github/workflows/mattpocock-skills-reviewer.md +++ b/.github/workflows/mattpocock-skills-reviewer.md @@ -13,6 +13,14 @@ permissions: contents: read pull-requests: read copilot-requests: write +skills: + - mattpocock/skills/diagnosing-bugs@801dca688564c529fa84f247f64472520d9ebe28 + - mattpocock/skills/tdd@801dca688564c529fa84f247f64472520d9ebe28 + - mattpocock/skills/improve-codebase-architecture@801dca688564c529fa84f247f64472520d9ebe28 + - mattpocock/skills/grill-with-docs@801dca688564c529fa84f247f64472520d9ebe28 + - mattpocock/skills/to-prd@801dca688564c529fa84f247f64472520d9ebe28 + - mattpocock/skills/codebase-design@801dca688564c529fa84f247f64472520d9ebe28 + - mattpocock/skills/domain-modeling@801dca688564c529fa84f247f64472520d9ebe28 sandbox: agent: @@ -28,35 +36,6 @@ imports: min-integrity: approved - shared/otlp.md pre-agent-steps: - - name: Upgrade gh CLI - run: | - bash "${RUNNER_TEMP}/gh-aw/actions/install_gh_cli.sh" - GH_VERSION=$(gh --version | head -1 | grep -oP '\d+\.\d+\.\d+') - echo "gh version: ${GH_VERSION}" - REQUIRED="2.90.0" - if ! printf '%s\n%s\n' "$REQUIRED" "$GH_VERSION" | sort -V -C; then - echo "::error::gh ${GH_VERSION} is older than required ${REQUIRED} (gh skill support requires v2.90+)" - exit 1 - fi - - name: Install Matt Pocock skills - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - SKILLS_DST="${RUNNER_TEMP}/gh-aw/mattpocock-skills" - mkdir -p "${SKILLS_DST}" - # Install only the skills referenced in this workflow's prompt, rather than - # all published skills, to reduce install time and network overhead. - for skill in diagnosing-bugs tdd improve-codebase-architecture grill-with-docs to-prd codebase-design domain-modeling; do - gh skill install mattpocock/skills "${skill}" --dir "${SKILLS_DST}" --force - done - SKILL_COUNT=$(find "${SKILLS_DST}" -name "SKILL.md" | wc -l) - echo "Installed ${SKILL_COUNT} skill(s):" - find "${SKILLS_DST}" -name "SKILL.md" | head -20 - if [ "${SKILL_COUNT}" -eq 0 ]; then - echo "::error::No SKILL.md files found after installing mattpocock/skills" - exit 1 - fi - name: Pre-fetch PR diff env: GH_TOKEN: ${{ github.token }} diff --git a/actions/setup/js/generate_aw_info.cjs b/actions/setup/js/generate_aw_info.cjs index 1343c6efcce..65dcb3452b4 100644 --- a/actions/setup/js/generate_aw_info.cjs +++ b/actions/setup/js/generate_aw_info.cjs @@ -128,6 +128,12 @@ async function main(core, ctx) { awInfo.features = features; } + const skills = parseSkillsFromEnv(core); + if (skills) { + awInfo.skills = skills; + core.info(`Configured frontmatter skills (${skills.length}): ${skills.join(", ")}`); + } + // Include aw_context when the workflow was triggered by a caller that relayed // orchestration context via workflow inputs or repository_dispatch client payload. // Validates JSON format and structure before populating the context key in aw_info.json. @@ -218,6 +224,38 @@ async function main(core, ctx) { } } + /** + * Parse optional skills list from GH_AW_INFO_SKILLS. + * @param {typeof import('@actions/core')} core + * @returns {string[] | null} + */ + function parseSkillsFromEnv(core) { + const skillsEnv = process.env.GH_AW_INFO_SKILLS; + if (!skillsEnv) { + return null; + } + try { + const parsed = JSON.parse(skillsEnv); + if (!Array.isArray(parsed)) { + core.warning("GH_AW_INFO_SKILLS must be a JSON array, ignoring"); + return null; + } + const skills = []; + for (const [index, value] of parsed.entries()) { + if (typeof value === "string" && value.length > 0) { + skills.push(value); + continue; + } + core.warning(`Ignoring invalid GH_AW_INFO_SKILLS[${index}] value: ${JSON.stringify(value)}`); + } + return skills.length > 0 ? skills : null; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + core.warning(`Failed to parse GH_AW_INFO_SKILLS: ${skillsEnv} (${message})`); + return null; + } + } + core.info("Generated aw_info.json at: " + tmpPath); core.info(JSON.stringify(awInfo, null, 2)); diff --git a/actions/setup/js/generate_aw_info.test.cjs b/actions/setup/js/generate_aw_info.test.cjs index b84d839c2c5..6ec96e3f691 100644 --- a/actions/setup/js/generate_aw_info.test.cjs +++ b/actions/setup/js/generate_aw_info.test.cjs @@ -61,6 +61,7 @@ describe("generate_aw_info.cjs", () => { process.env.GH_AW_INFO_FRONTMATTER_EMOJI = ""; process.env.GH_AW_INFO_BODY_MODIFIED = ""; process.env.GH_AW_INFO_FEATURES = ""; + process.env.GH_AW_INFO_SKILLS = ""; // Dynamic import to get fresh module state const module = await import("./generate_aw_info.cjs"); @@ -117,6 +118,46 @@ describe("generate_aw_info.cjs", () => { }); }); + it("should include frontmatter skills and log them with core.info", async () => { + process.env.GH_AW_INFO_SKILLS = JSON.stringify(["githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6", "githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6"]); + + await main(mockCore, mockContext); + + const awInfo = JSON.parse(fs.readFileSync(awInfoPath, "utf8")); + expect(awInfo.skills).toEqual(["githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6", "githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6"]); + expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Configured frontmatter skills")); + }); + + it("should warn and ignore non-array GH_AW_INFO_SKILLS", async () => { + process.env.GH_AW_INFO_SKILLS = JSON.stringify("not-an-array"); + + await main(mockCore, mockContext); + + const awInfo = JSON.parse(fs.readFileSync(awInfoPath, "utf8")); + expect(awInfo.skills).toBeUndefined(); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("GH_AW_INFO_SKILLS must be a JSON array")); + }); + + it("should warn and skip non-string GH_AW_INFO_SKILLS entries", async () => { + process.env.GH_AW_INFO_SKILLS = JSON.stringify([42, "githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6"]); + + await main(mockCore, mockContext); + + const awInfo = JSON.parse(fs.readFileSync(awInfoPath, "utf8")); + expect(awInfo.skills).toEqual(["githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6"]); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("GH_AW_INFO_SKILLS[0]")); + }); + + it("should warn and ignore invalid GH_AW_INFO_SKILLS JSON", async () => { + process.env.GH_AW_INFO_SKILLS = "not-json"; + + await main(mockCore, mockContext); + + const awInfo = JSON.parse(fs.readFileSync(awInfoPath, "utf8")); + expect(awInfo.skills).toBeUndefined(); + expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to parse GH_AW_INFO_SKILLS")); + }); + it("should persist custom token weights in aw_info.json", async () => { process.env.GH_AW_INFO_TOKEN_WEIGHTS = JSON.stringify({ token_class_weights: { output: 8.0 }, diff --git a/docs/adr/42426-declare-skills-in-workflow-frontmatter.md b/docs/adr/42426-declare-skills-in-workflow-frontmatter.md new file mode 100644 index 00000000000..4edec96da84 --- /dev/null +++ b/docs/adr/42426-declare-skills-in-workflow-frontmatter.md @@ -0,0 +1,51 @@ +# ADR-42426: Declare External Skills in Workflow Frontmatter + +**Date**: 2026-06-30 +**Status**: Draft +**Deciders**: Unknown (copilot-swe-agent, pelikhan) + +--- + +### Context + +The gh-aw system compiles Markdown workflow definitions into GitHub Actions YAML. Agent engines (Claude, Copilot, etc.) support loadable "skills" — small instruction files that extend agent capabilities — stored in engine-specific directories (e.g., `.claude/skills/`). Previously, skill directory setup was gated entirely behind the `inline-agents` feature flag, with no mechanism for workflow authors to declare specific external skills their workflow requires. Authors who needed external skills had to manually add `run:` steps, which required knowledge of internal path conventions, bypassed activation artifact persistence, and provided no SHA-pinning for reproducibility. The `gh skill` CLI (available since v2.90) provides a standardized install interface that the compiler can invoke on behalf of declarative skill references. + +### Decision + +We will add a `skills` array to the workflow frontmatter schema, allowing workflow authors to declare external skill references pinned to 40-character commit SHAs (e.g., `owner/repo@` or `owner/repo/skill/path@`). During the activation job, the compiler will emit steps that upgrade `gh` to ≥2.90, then call `gh skill install` for each reference, routing installed files to the engine-specific skill directory. The installed skill directory will be uploaded in the activation artifact and restored in the main job, consistent with how inline sub-agent assets are persisted. The compiler will also emit the skill list via `GH_AW_INFO_SKILLS` so `aw_info.json` exposes configured skills to the running engine. + +### Alternatives Considered + +#### Alternative 1: Manual `run:` steps in workflow body + +Workflow authors could add their own `run:` steps before the agent executes to call `gh skill install` manually. This was the status quo workaround. It was rejected because it requires authors to know internal skill directory paths per engine, provides no integration with activation artifact upload/restore (skills would be lost between jobs), and duplicates boilerplate across every workflow that needs skills. It also offers no compile-time validation of reference format. + +#### Alternative 2: Bundle skills into the existing `inline-agents` feature flag + +The `inline-agents` flag already establishes skill directory infrastructure. Skills could have been configured as a sub-option of that flag rather than an independent frontmatter key. This was rejected because `inline-agents` is a runtime behavioral flag for sub-agent spawning — it is conceptually orthogonal to which skills a workflow declares. Conflating the two would force authors to enable sub-agent spawning just to use skills, and would make the feature flag semantics confusing. Keeping skills as a first-class frontmatter field mirrors how other workflow-level configuration (tools, engine, metadata) is declared. + +#### Alternative 3: Skills as a repository-level configuration file + +A `.gh-aw-skills.yml` file at the repository root could list skills to install for all workflows in the repo. This was not pursued because it cannot express per-workflow skill sets, provides no per-ref pinning at the workflow level, and would complicate the existing single-file compilation model where each workflow Markdown file is self-contained. + +### Consequences + +#### Positive +- Workflow authors can declaratively specify skill dependencies with SHA-pinned references, making skill sets reproducible and auditable via git history. +- Engine skill directories are populated before the agent runs, enabling consistent skill discovery without engine-specific setup code. +- `aw_info.json` now carries the configured skill list, so engines can introspect which skills were requested without parsing frontmatter themselves. +- Activation artifact upload and main-job restore are unconditionally extended to cover the skill directory when skills are declared, preventing cross-job data loss without manual configuration. + +#### Negative +- Skill installation requires `gh` CLI ≥2.90. Workflows using this feature will fail activation on runners with older versions; operators must ensure runner images are up to date. +- Activation job run time increases by at least one `gh skill install` invocation per reference, plus the version check step. High-skill-count workflows may see noticeable activation overhead. +- The `isRepositorySkillSpec` heuristic (counting `/` separators to determine `--all` install) is a fragile proxy for intent; a repo with exactly two path segments triggers bulk install even if the author wanted only one skill from that path. + +#### Neutral +- GitHub Actions expressions (`${{ inputs.skill_ref }}`) are accepted as skill refs at compile time without SHA validation, deferring resolution to runtime. This is consistent with how other expression-valued fields (e.g., `max-daily-ai-credits`) are handled. +- The `GH_AW_INFO_SKILLS` environment variable follows the existing `GH_AW_INFO_*` naming convention used for features and other runtime metadata. +- Existing workflows with no `skills` key are unaffected; all new code paths are gated on `len(data.Skills) > 0`. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index b3ddb8943c2..ead70459a54 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -56,6 +56,27 @@ ["ci", "testing"] ] }, + "skills": { + "type": "array", + "description": "Optional list of external skill references to install during activation. Supports repository-wide installs (`owner/repo@`) and path-scoped installs (`owner/repo/skill/path@`). Static references must be pinned to a full 40-character lowercase commit SHA. GitHub Actions expressions (`${{ ... }}`) are also accepted and are evaluated at runtime.", + "items": { + "type": "string", + "oneOf": [ + { + "pattern": "^[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+)*)?@[0-9a-f]{40}$" + }, + { + "pattern": "^[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+)*)?@\\$\\{\\{.+\\}\\}$", + "description": "Skill reference with a GitHub Actions expression in the ref position." + }, + { + "pattern": "^\\$\\{\\{.+\\}\\}$", + "description": "GitHub Actions expression that resolves to a full skill reference string at runtime." + } + ] + }, + "examples": [["githubnext/awesome-skills@1f181b37d3fe5862ab590648f25a292e345b5de6"], ["githubnext/awesome-skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6"]] + }, "metadata": { "type": "object", "description": "Optional metadata field for storing custom key-value pairs compatible with the custom agent spec. Key names are limited to 64 characters, and values are limited to 1024 characters.", diff --git a/pkg/workflow/activation_skills_step_test.go b/pkg/workflow/activation_skills_step_test.go new file mode 100644 index 00000000000..1d417b57bec --- /dev/null +++ b/pkg/workflow/activation_skills_step_test.go @@ -0,0 +1,91 @@ +//go:build !integration + +package workflow + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildActivationJob_AddsFrontmatterSkillsInstallSteps(t *testing.T) { + compiler := NewCompiler(WithVersion("dev")) + compiler.SetActionMode(ActionModeDev) + + data := &WorkflowData{ + Name: "skills-workflow", + On: `"on": + workflow_dispatch:`, + AI: "copilot", + EngineConfig: &EngineConfig{ + ID: "claude", + }, + Skills: []string{ + "githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6", + "githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6", + }, + } + + job, err := compiler.buildActivationJob(data, false, "", "skills.lock.yml") + require.NoError(t, err) + require.NotNil(t, job) + + steps := strings.Join(job.Steps, "") + assert.Contains(t, steps, "Upgrade gh CLI for frontmatter skills", "expected gh upgrade step in activation job") + assert.Contains(t, steps, "Install frontmatter skills", "expected frontmatter skills install step in activation job") + assert.Contains(t, steps, "GH_AW_SKILL_DIR: \".claude/skills\"", "expected engine skill directory env var") + assert.Contains(t, steps, "GH_AW_SKILLS_SUMMARY: '[\"githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6\",\"githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6\"]'", "expected summary env var for requested skills") + assert.Contains(t, steps, "GH_AW_SKILL_SPEC_0: \"githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6\"", "expected first skill env var") + assert.Contains(t, steps, "GH_AW_SKILL_SPEC_1: \"githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6\"", "expected second skill env var") + assert.Contains(t, steps, "skill_spec=\"${GH_AW_SKILL_SPEC_0}\"", "expected runtime install loop to read first skill from env") + assert.Contains(t, steps, "install_args+=(--all)", "expected runtime repository-scope detection") + assert.Contains(t, steps, "gh skill install \"${skill_spec}\" \"${install_args[@]}\" --dir \"${SKILLS_DST}\" --force", "expected runtime install command to use quoted env values") + assert.Contains(t, steps, "### Frontmatter skills installed", "expected step summary output") +} + +func TestBuildActivationJob_AddsExpressionSkillInstallSteps(t *testing.T) { + compiler := NewCompiler(WithVersion("dev")) + compiler.SetActionMode(ActionModeDev) + + data := &WorkflowData{ + Name: "skills-workflow", + On: `"on": + workflow_dispatch:`, + AI: "copilot", + Skills: []string{ + "${{ inputs.skill_ref }}", + "githubnext/skills@${{ github.sha }}", + }, + } + + job, err := compiler.buildActivationJob(data, false, "", "skills.lock.yml") + require.NoError(t, err) + require.NotNil(t, job) + + steps := strings.Join(job.Steps, "") + assert.Contains(t, steps, "GH_AW_SKILL_SPEC_0: \"${{ inputs.skill_ref }}\"", "expected whole-expression skill env var") + assert.Contains(t, steps, "GH_AW_SKILL_SPEC_1: \"githubnext/skills@${{ github.sha }}\"", "expected expression-ref skill env var") + assert.NotContains(t, steps, "echo \"Installing skill reference: ${{ inputs.skill_ref }}\"", "expression should not be interpolated directly into the run script") +} + +func TestBuildActivationJob_NoSkillsStepsWhenSkillsAbsent(t *testing.T) { + compiler := NewCompiler(WithVersion("dev")) + compiler.SetActionMode(ActionModeDev) + + data := &WorkflowData{ + Name: "no-skills-workflow", + On: `"on": + workflow_dispatch:`, + AI: "copilot", + } + + job, err := compiler.buildActivationJob(data, false, "", "no-skills.lock.yml") + require.NoError(t, err) + require.NotNil(t, job) + + steps := strings.Join(job.Steps, "") + assert.NotContains(t, steps, "Upgrade gh CLI for frontmatter skills", "expected no gh upgrade step without frontmatter skills") + assert.NotContains(t, steps, "Install frontmatter skills", "expected no skill install step without frontmatter skills") +} diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index e5a8b26caa5..557b7c1448a 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -41,6 +41,9 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate if err := c.addActivationRepositoryAndOutputSteps(ctx); err != nil { return nil, fmt.Errorf("failed to add activation repository and output steps: %w", err) } + if err := c.addActivationSkillInstallSteps(ctx); err != nil { + return nil, fmt.Errorf("failed to add skill install steps: %w", err) + } if err := c.addActivationCommandAndLabelOutputs(ctx); err != nil { return nil, fmt.Errorf("failed to add activation command and label outputs: %w", err) } diff --git a/pkg/workflow/compiler_activation_job_builder.go b/pkg/workflow/compiler_activation_job_builder.go index 1fabdd4b002..cc14f6e5dd8 100644 --- a/pkg/workflow/compiler_activation_job_builder.go +++ b/pkg/workflow/compiler_activation_job_builder.go @@ -519,6 +519,75 @@ func (c *Compiler) addActivationVersionCheckStep(ctx *activationJobBuildContext) ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("check_version_updates.cjs")) } +func (c *Compiler) addActivationSkillInstallSteps(ctx *activationJobBuildContext) error { + if len(ctx.data.Skills) == 0 { + return nil + } + + engineID := "" + if ctx.data.EngineConfig != nil { + engineID = ctx.data.EngineConfig.ID + } + skillDir := GetEngineSkillDir(engineID) + skillSpecsJSON, err := json.Marshal(ctx.data.Skills) + if err != nil { + return fmt.Errorf("marshal activation skill specs: %w", err) + } + escapedSkillSpecsJSON := strings.ReplaceAll(string(skillSpecsJSON), "'", "''") + + ctx.steps = append(ctx.steps, " - name: Upgrade gh CLI for frontmatter skills\n") + ctx.steps = append(ctx.steps, " run: |\n") + ctx.steps = append(ctx.steps, " set -euo pipefail\n") + ctx.steps = append(ctx.steps, " bash \"${RUNNER_TEMP}/gh-aw/actions/install_gh_cli.sh\"\n") + ctx.steps = append(ctx.steps, " GH_VERSION=$(gh --version | awk 'NR==1 {print $3}')\n") + ctx.steps = append(ctx.steps, " REQUIRED=\"2.90.0\"\n") + ctx.steps = append(ctx.steps, " echo \"gh version: ${GH_VERSION}\"\n") + ctx.steps = append(ctx.steps, " if ! printf '%s\\n%s\\n' \"$REQUIRED\" \"$GH_VERSION\" | sort -V -C; then\n") + ctx.steps = append(ctx.steps, " echo \"::error::gh ${GH_VERSION} is older than required ${REQUIRED} (gh skill support requires v2.90+)\"\n") + ctx.steps = append(ctx.steps, " exit 1\n") + ctx.steps = append(ctx.steps, " fi\n") + + ctx.steps = append(ctx.steps, " - name: Install frontmatter skills\n") + ctx.steps = append(ctx.steps, " env:\n") + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_TOKEN: %s\n", c.resolveActivationToken(ctx.data))) + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_SKILL_DIR: %q\n", skillDir)) + ctx.steps = append(ctx.steps, fmt.Sprintf(" GH_AW_SKILLS_SUMMARY: '%s'\n", escapedSkillSpecsJSON)) + for i, skillSpec := range ctx.data.Skills { + ctx.steps = append(ctx.steps, formatYAMLEnv(" ", fmt.Sprintf("GH_AW_SKILL_SPEC_%d", i), skillSpec)) + } + ctx.steps = append(ctx.steps, " run: |\n") + ctx.steps = append(ctx.steps, " set -euo pipefail\n") + ctx.steps = append(ctx.steps, " SKILLS_DST=\"/tmp/gh-aw/${GH_AW_SKILL_DIR}\"\n") + ctx.steps = append(ctx.steps, " mkdir -p \"${SKILLS_DST}\"\n") + ctx.steps = append(ctx.steps, " echo \"Installing frontmatter skills to ${SKILLS_DST}\"\n") + ctx.steps = append(ctx.steps, " echo \"Existing skills at destination may be replaced (--force) to ensure pinned refs are up to date\"\n") + for i := range ctx.data.Skills { + ctx.steps = append(ctx.steps, fmt.Sprintf(" skill_spec=\"${GH_AW_SKILL_SPEC_%d}\"\n", i)) + ctx.steps = append(ctx.steps, " echo \"Installing skill reference: ${skill_spec}\"\n") + // Keep this runtime owner/repo vs owner/repo/path detection aligned with + // isRepositorySkillSpec so expression-based refs behave the same after resolution. + ctx.steps = append(ctx.steps, " skill_base=\"${skill_spec%@*}\"\n") + ctx.steps = append(ctx.steps, " install_args=()\n") + ctx.steps = append(ctx.steps, " if [[ \"${skill_base}\" == */* && \"${skill_base}\" != */*/* ]]; then\n") + ctx.steps = append(ctx.steps, " install_args+=(--all)\n") + ctx.steps = append(ctx.steps, " fi\n") + ctx.steps = append(ctx.steps, " gh skill install \"${skill_spec}\" \"${install_args[@]}\" --dir \"${SKILLS_DST}\" --force\n") + } + ctx.steps = append(ctx.steps, " SKILL_COUNT=$(find \"${SKILLS_DST}\" -name \"SKILL.md\" | wc -l | tr -d '[:space:]')\n") + ctx.steps = append(ctx.steps, " echo \"Installed ${SKILL_COUNT} skill file(s)\"\n") + ctx.steps = append(ctx.steps, " core_summary_path=\"${GITHUB_STEP_SUMMARY:-}\"\n") + ctx.steps = append(ctx.steps, " if [ -n \"${core_summary_path}\" ]; then\n") + ctx.steps = append(ctx.steps, " {\n") + ctx.steps = append(ctx.steps, " echo \"### Frontmatter skills installed\"\n") + ctx.steps = append(ctx.steps, " echo \"\"\n") + ctx.steps = append(ctx.steps, " echo \"- Engine skill directory: \\`${GH_AW_SKILL_DIR}\\`\"\n") + ctx.steps = append(ctx.steps, " echo \"- Requested references: \\`${GH_AW_SKILLS_SUMMARY}\\`\"\n") + ctx.steps = append(ctx.steps, " echo \"- Installed SKILL.md files: ${SKILL_COUNT}\"\n") + ctx.steps = append(ctx.steps, " } >> \"${core_summary_path}\"\n") + ctx.steps = append(ctx.steps, " fi\n") + return nil +} + func (c *Compiler) addActivationTextOutputStep(ctx *activationJobBuildContext) error { if !ctx.data.NeedsTextOutput { return nil @@ -766,15 +835,19 @@ func (c *Compiler) addActivationArtifactUploadStep(ctx *activationJobBuildContex ctx.steps = append(ctx.steps, " /tmp/gh-aw/aw-prompts/prompt-import-tree.json\n") ctx.steps = append(ctx.steps, " /tmp/gh-aw/"+constants.GithubRateLimitsFilename+"\n") ctx.steps = append(ctx.steps, " /tmp/gh-aw/base\n") - // Include the engine-specific sub-agents staging directory (inline sub-agents are enabled by default). + engineID := "" + if ctx.data.EngineConfig != nil { + engineID = ctx.data.EngineConfig.ID + } + // Include the engine-specific sub-agent staging directory only when inline agents are enabled. if isFeatureEnabled(constants.FeatureFlag("inline-agents"), ctx.data) { - engineID := "" - if ctx.data.EngineConfig != nil { - engineID = ctx.data.EngineConfig.ID - } subAgentDir := GetEngineSubAgentDir(engineID) - skillDir := GetEngineSkillDir(engineID) ctx.steps = append(ctx.steps, fmt.Sprintf(" /tmp/gh-aw/%s\n", subAgentDir)) + } + // Always include the engine-specific skill directory when either inline skills are enabled + // or frontmatter skills are configured. + if isFeatureEnabled(constants.FeatureFlag("inline-agents"), ctx.data) || len(ctx.data.Skills) > 0 { + skillDir := GetEngineSkillDir(engineID) ctx.steps = append(ctx.steps, fmt.Sprintf(" /tmp/gh-aw/%s\n", skillDir)) } ctx.steps = append(ctx.steps, " if-no-files-found: ignore\n") diff --git a/pkg/workflow/compiler_orchestrator_frontmatter.go b/pkg/workflow/compiler_orchestrator_frontmatter.go index 61e708f4f1e..460ef2fc6ef 100644 --- a/pkg/workflow/compiler_orchestrator_frontmatter.go +++ b/pkg/workflow/compiler_orchestrator_frontmatter.go @@ -184,6 +184,10 @@ func (c *Compiler) parseFrontmatterSection(markdownPath string) (*frontmatterPar orchestratorFrontmatterLog.Printf("Main workflow frontmatter validation failed: %v", err) return nil, err } + if err := validateFrontmatterSkills(frontmatterForValidation); err != nil { + orchestratorFrontmatterLog.Printf("Skills frontmatter validation failed: %v", err) + return nil, err + } // Validate event filter mutual exclusivity (branches/branches-ignore, paths/paths-ignore) if err := ValidateEventFilters(frontmatterForValidation); err != nil { diff --git a/pkg/workflow/compiler_orchestrator_frontmatter_test.go b/pkg/workflow/compiler_orchestrator_frontmatter_test.go index 6f91702622d..d765f894221 100644 --- a/pkg/workflow/compiler_orchestrator_frontmatter_test.go +++ b/pkg/workflow/compiler_orchestrator_frontmatter_test.go @@ -5,6 +5,7 @@ package workflow import ( "os" "path/filepath" + "strings" "testing" "github.com/github/gh-aw/pkg/testutil" @@ -265,6 +266,62 @@ engine: copilot } } +func TestParseFrontmatterSection_InvalidSkillsRef(t *testing.T) { + tmpDir := testutil.TempDir(t, "frontmatter-invalid-skills-ref") + + testContent := `--- +on: workflow_dispatch +engine: copilot +skills: + - githubnext/skills@main +--- + +# Workflow +` + + testFile := filepath.Join(tmpDir, "invalid-skills.md") + require.NoError(t, os.WriteFile(testFile, []byte(testContent), 0644)) + + compiler := NewCompiler() + result, err := compiler.parseFrontmatterSection(testFile) + + require.Error(t, err) + assert.Nil(t, result) + assert.True(t, + strings.Contains(err.Error(), "40-char-sha") || strings.Contains(err.Error(), "does not match pattern"), + "expected skills validation error, got: %v", err, + ) +} + +func TestParseFrontmatterSection_ExpressionSkillsRef(t *testing.T) { + tmpDir := testutil.TempDir(t, "frontmatter-expression-skills-ref") + + testContent := `--- +on: workflow_dispatch +engine: copilot +skills: + - ${{ inputs.skill_ref }} + - githubnext/skills@${{ github.sha }} +--- + +# Workflow +` + + testFile := filepath.Join(tmpDir, "expression-skills.md") + require.NoError(t, os.WriteFile(testFile, []byte(testContent), 0644)) + + compiler := NewCompiler() + result, err := compiler.parseFrontmatterSection(testFile) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.frontmatterResult) + assert.Equal(t, []any{ + "${{ inputs.skill_ref }}", + "githubnext/skills@${{ github.sha }}", + }, result.frontmatterResult.Frontmatter["skills"]) +} + // TestParseFrontmatterSection_FileReadError tests file I/O error handling func TestParseFrontmatterSection_FileReadError(t *testing.T) { compiler := NewCompiler() diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 35634d718d7..c578d374abe 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -480,6 +480,7 @@ type WorkflowData struct { TrackerID string // optional tracker identifier for created assets (min 8 chars, alphanumeric + hyphens/underscores) MaxDailyAICredits *string // optional 24-hour per-workflow ET threshold (numeric string or GitHub Actions expression) ImportedFiles []string // list of files imported via imports field (rendered as comment in lock file) + Skills []string // skill specs from frontmatter (owner/repo@sha or owner/repo/skill/path@sha) ImportedMarkdown string // Only imports WITH inputs (for compile-time substitution) ImportPaths []string // Import file paths for runtime-import macro generation (imports without inputs) PromptImports []parser.PromptImportEntry diff --git a/pkg/workflow/compiler_yaml.go b/pkg/workflow/compiler_yaml.go index 14be0e8917a..23f983cf9eb 100644 --- a/pkg/workflow/compiler_yaml.go +++ b/pkg/workflow/compiler_yaml.go @@ -965,6 +965,14 @@ func (c *Compiler) generateCreateAwInfo(yaml *strings.Builder, data *WorkflowDat fmt.Fprintf(yaml, " GH_AW_INFO_FEATURES: '%s'\n", escapedFeaturesJSON) } } + if len(data.Skills) > 0 { + if skillsJSON, err := json.Marshal(data.Skills); err == nil { + escapedSkillsJSON := strings.ReplaceAll(string(skillsJSON), "'", "''") + fmt.Fprintf(yaml, " GH_AW_INFO_SKILLS: '%s'\n", escapedSkillsJSON) + } else { + compilerYamlLog.Printf("Failed to marshal skills for GH_AW_INFO_SKILLS, engine will not receive skill list: %v", err) + } + } fmt.Fprintf(yaml, " uses: %s\n", getCachedActionPin("actions/github-script", data)) yaml.WriteString(" with:\n") yaml.WriteString(" script: |\n") diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go index c1fdac53463..415b241f125 100644 --- a/pkg/workflow/compiler_yaml_main_job.go +++ b/pkg/workflow/compiler_yaml_main_job.go @@ -437,6 +437,10 @@ func (c *Compiler) generateEngineInstallAndPreAgentSteps(yaml *strings.Builder, // is not clobbered. Inline sub-agents are enabled by default. if isFeatureEnabled(constants.FeatureFlag("inline-agents"), data) { generateRestoreInlineSubAgentsStep(yaml, data) + } + // Restore the engine-specific skills directory when inline skills are enabled or when + // explicit frontmatter skills were installed during activation. + if isFeatureEnabled(constants.FeatureFlag("inline-agents"), data) || len(data.Skills) > 0 { generateRestoreInlineSkillsStep(yaml, data) } diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 40261210574..b769314d88e 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -321,6 +321,7 @@ type FrontmatterConfig struct { Strict *bool `json:"strict,omitempty"` // Pointer to distinguish unset from false Private *bool `json:"private,omitempty"` // If true, workflow cannot be added to other repositories Labels []string `json:"labels,omitempty"` + Skills []string `json:"skills,omitempty"` // Configuration sections - using strongly-typed structs Tools *ToolsConfig `json:"tools,omitempty"` diff --git a/pkg/workflow/frontmatter_types_test.go b/pkg/workflow/frontmatter_types_test.go index 18a4a890dfe..4c2f3c74799 100644 --- a/pkg/workflow/frontmatter_types_test.go +++ b/pkg/workflow/frontmatter_types_test.go @@ -55,6 +55,25 @@ func TestParseFrontmatterConfig(t *testing.T) { } }) + t.Run("parses skills array", func(t *testing.T) { + frontmatter := map[string]any{ + "skills": []any{ + "githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6", + "githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6", + }, + } + + config, err := ParseFrontmatterConfig(frontmatter) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + require.Equal(t, []string{ + "githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6", + "githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6", + }, config.Skills) + }) + t.Run("parses complete workflow config", func(t *testing.T) { frontmatter := map[string]any{ "name": "full-workflow", diff --git a/pkg/workflow/skills_frontmatter.go b/pkg/workflow/skills_frontmatter.go new file mode 100644 index 00000000000..796217bc3ec --- /dev/null +++ b/pkg/workflow/skills_frontmatter.go @@ -0,0 +1,51 @@ +package workflow + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +var skillSpecRegexp = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*)?@[0-9a-f]{40}$`) +var skillSpecExpressionRefRegexp = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*)?@\$\{\{.+\}\}$`) +var githubActionsExpressionRegexp = regexp.MustCompile(`^\$\{\{.+\}\}$`) + +func validateFrontmatterSkills(frontmatter map[string]any) error { + rawSkills, hasSkills := frontmatter["skills"] + if !hasSkills { + return nil + } + + skills, ok := rawSkills.([]any) + if !ok { + return errors.New("skills must be an array of skill references") + } + + for i, rawSkill := range skills { + skillSpec, ok := rawSkill.(string) + if !ok || strings.TrimSpace(skillSpec) == "" { + return fmt.Errorf("skills[%d] must be a non-empty string", i) + } + if githubActionsExpressionRegexp.MatchString(skillSpec) || skillSpecExpressionRefRegexp.MatchString(skillSpec) { + continue + } + if !skillSpecRegexp.MatchString(skillSpec) { + return fmt.Errorf( + "skills[%d] must use owner/repo@<40-char-sha>, owner/repo/skill/path@<40-char-sha>, or a GitHub Actions expression: %q", + i, + skillSpec, + ) + } + } + + return nil +} + +func isRepositorySkillSpec(skillSpec string) bool { + base, _, _ := strings.Cut(skillSpec, "@") + // owner/repo has exactly one slash; owner/repo/skill/path has two or more. + // Expression-only specs have no static @ suffix and are treated as path-scoped + // until the resolved runtime value is inspected by the install step. + return strings.Count(base, "/") == 1 +} diff --git a/pkg/workflow/skills_frontmatter_test.go b/pkg/workflow/skills_frontmatter_test.go new file mode 100644 index 00000000000..829f05b132c --- /dev/null +++ b/pkg/workflow/skills_frontmatter_test.go @@ -0,0 +1,81 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateFrontmatterSkills(t *testing.T) { + t.Run("accepts pinned repository and path specs", func(t *testing.T) { + err := validateFrontmatterSkills(map[string]any{ + "skills": []any{ + "githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6", + "githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6", + }, + }) + require.NoError(t, err) + }) + + t.Run("rejects non-sha refs", func(t *testing.T) { + err := validateFrontmatterSkills(map[string]any{ + "skills": []any{ + "githubnext/skills@main", + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "40-char-sha") + }) + + t.Run("rejects 39-char sha", func(t *testing.T) { + err := validateFrontmatterSkills(map[string]any{ + "skills": []any{ + "githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de", + }, + }) + require.Error(t, err) + }) + + t.Run("rejects uppercase sha chars", func(t *testing.T) { + err := validateFrontmatterSkills(map[string]any{ + "skills": []any{ + "githubnext/skills@1F181B37D3FE5862AB590648F25A292E345B5DE6", + }, + }) + require.Error(t, err) + }) + + t.Run("accepts github actions expressions", func(t *testing.T) { + err := validateFrontmatterSkills(map[string]any{ + "skills": []any{ + "${{ inputs.skill_ref }}", + "githubnext/skills@${{ github.sha }}", + }, + }) + require.NoError(t, err) + }) + + t.Run("accepts empty skills array", func(t *testing.T) { + err := validateFrontmatterSkills(map[string]any{ + "skills": []any{}, + }) + require.NoError(t, err) + }) + + t.Run("rejects non-string items", func(t *testing.T) { + err := validateFrontmatterSkills(map[string]any{ + "skills": []any{42}, + }) + require.Error(t, err) + }) + +} + +func TestIsRepositorySkillSpec(t *testing.T) { + require.True(t, isRepositorySkillSpec("githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6"), "owner/repo@sha should be treated as a repository skill spec") + require.False(t, isRepositorySkillSpec("githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6"), "owner/repo/skill/path@sha should be treated as a path-scoped skill spec") + require.True(t, isRepositorySkillSpec("githubnext/skills@${{ github.sha }}"), "owner/repo@expression should still be treated as a repository skill spec") + require.False(t, isRepositorySkillSpec("${{ inputs.skill_ref }}"), "whole-expression specs should defer repository/path detection to runtime") +} diff --git a/pkg/workflow/workflow_builder.go b/pkg/workflow/workflow_builder.go index 62db0f5abb5..3114a2291cd 100644 --- a/pkg/workflow/workflow_builder.go +++ b/pkg/workflow/workflow_builder.go @@ -48,6 +48,7 @@ func (c *Compiler) buildInitialWorkflowData( TrackerID: toolsResult.trackerID, MaxDailyAICredits: resolveMaxDailyAIC(result.Frontmatter, importsResult.MergedMaxDailyAICredits), ImportedFiles: importsResult.ImportedFiles, + Skills: extractFrontmatterSkills(toolsResult.parsedFrontmatter, result.Frontmatter), ImportedMarkdown: toolsResult.importedMarkdown, // Only imports WITH inputs ImportPaths: toolsResult.importPaths, // Import paths for runtime-import macros (imports without inputs) PromptImports: toolsResult.promptImports, // Ordered prompt contributions from imports @@ -195,6 +196,33 @@ func extractLSPConfig(parsedFrontmatter *FrontmatterConfig, frontmatter map[stri return lsp } +func extractFrontmatterSkills(parsedFrontmatter *FrontmatterConfig, frontmatter map[string]any) []string { + if parsedFrontmatter != nil && len(parsedFrontmatter.Skills) > 0 { + return append([]string(nil), parsedFrontmatter.Skills...) + } + + // Fall back to raw frontmatter when ParseFrontmatterConfig failed for non-skills reasons + // (e.g. unrecognized tool shapes). Safe because validateFrontmatterSkills already ran + // and succeeded on this frontmatter before we reach this point. + rawSkills, ok := frontmatter["skills"].([]any) + if !ok || len(rawSkills) == 0 { + return nil + } + + skills := make([]string, 0, len(rawSkills)) + for _, rawSkill := range rawSkills { + skill, ok := rawSkill.(string) + if !ok || skill == "" { + continue + } + skills = append(skills, skill) + } + if len(skills) == 0 { + return nil + } + return skills +} + func extractMainModelCostsOverlay(toolsResult *toolsProcessingResult, frontmatter map[string]any) map[string]any { // Fall back to raw frontmatter when ParseFrontmatterConfig failed (e.g. due to unrecognized // tool config shapes like bash: ["*"]).