diff --git a/.github/aw/skills.md b/.github/aw/skills.md index adb72f04fd5..2323581f2e6 100644 --- a/.github/aw/skills.md +++ b/.github/aw/skills.md @@ -18,7 +18,7 @@ find "${GITHUB_WORKSPACE}" -name "SKILL.md" -maxdepth 6 ## Frontmatter `skills:` (Preferred for External Skills) -Declare external skills to install at activation time with the top-level `skills:` array. +Declare skills to install at activation time with the top-level `skills:` array. The compiler emits the activation steps, prepares the required `gh` support, installs each skill, and wires it into the engine. Do **not** add manual `gh` setup or `gh skill install` steps for this. @@ -27,6 +27,9 @@ skills: # Shared auth via the workflow activation token - mattpocock/skills/tdd@801dca688564c529fa84f247f64472520d9ebe28 + # Local skill path for development (installed with --from-local) + - .github/skills/my-skill + # Per-skill token for a private skill repository - skill: mattpocock/skills/diagnosing-bugs@801dca688564c529fa84f247f64472520d9ebe28 github-token: ${{ secrets.MATT_SKILLS_PAT || secrets.GITHUB_TOKEN }} @@ -39,6 +42,7 @@ skills: ``` - Static references must be pinned to a full 40-character lowercase commit SHA; `${{ ... }}` expressions are allowed in the ref position and resolved at runtime. +- Local paths (for example, `skills/my-skill` or `.github/skills/my-skill`) are supported for local development and are installed via `--from-local`. - Object entries set per-skill auth via `github-token` or `github-app`. - Use `skills:` for external skill installs and `imports:` for prompt/context files you want merged into the workflow. diff --git a/actions/setup/js/install_frontmatter_skills.cjs b/actions/setup/js/install_frontmatter_skills.cjs index 4d6664529de..4d11a96cb0d 100644 --- a/actions/setup/js/install_frontmatter_skills.cjs +++ b/actions/setup/js/install_frontmatter_skills.cjs @@ -20,6 +20,18 @@ function parseSkillSpecs(rawSkills) { * @typedef {{args: string[]; displaySpec: string}} SkillInstallCommand */ +/** + * Reports whether a skill spec is a local path reference — one that has no + * "@" separator and is not a GitHub Actions expression. Local refs are + * installed with --from-local instead of fetching from a remote repository. + * + * @param {string} skillSpec + * @returns {boolean} + */ +function isLocalSkillRef(skillSpec) { + return skillSpec !== "" && !skillSpec.startsWith("${{") && !skillSpec.includes("@"); +} + /** * @param {string} skillSpec * @param {string} skillsDst @@ -27,13 +39,24 @@ function parseSkillSpecs(rawSkills) { * @returns {SkillInstallCommand} */ function buildSkillInstallCommand(skillSpec, skillsDst, skillInstallAgent = "") { + const agentArgs = skillInstallAgent ? ["--agent", skillInstallAgent] : []; + + // Local path reference: install from the filesystem using --from-local. + // The skill name is derived from the last path component. + if (isLocalSkillRef(skillSpec)) { + const skillName = skillSpec.replace(/\\/g, "/").split("/").filter(Boolean).pop() || skillSpec; + return { + displaySpec: skillSpec, + args: ["skill", "install", skillSpec, skillName, "--from-local", ...agentArgs, "--dir", skillsDst, "--force"], + }; + } + const atIndex = skillSpec.lastIndexOf("@"); const hasPin = atIndex >= 0; const skillBase = hasPin ? skillSpec.slice(0, atIndex) : skillSpec; const skillRef = hasPin ? skillSpec.slice(atIndex + 1) : ""; const parts = skillBase.split("/"); const pinArgs = skillRef ? ["--pin", skillRef] : []; - const agentArgs = skillInstallAgent ? ["--agent", skillInstallAgent] : []; if (parts.length >= 3) { return { @@ -189,4 +212,4 @@ async function main() { await writeSkillSummary(skillDir, skills, installedSkillCount, failures); } -module.exports = { main, parseSkillSpecs, buildSkillInstallCommand, countInstalledSkillFiles, appendSkillInstallFailure }; +module.exports = { main, parseSkillSpecs, buildSkillInstallCommand, isLocalSkillRef, countInstalledSkillFiles, appendSkillInstallFailure }; diff --git a/actions/setup/js/install_frontmatter_skills.test.cjs b/actions/setup/js/install_frontmatter_skills.test.cjs index 061b09c599a..9911692d385 100644 --- a/actions/setup/js/install_frontmatter_skills.test.cjs +++ b/actions/setup/js/install_frontmatter_skills.test.cjs @@ -105,8 +105,37 @@ describe("install_frontmatter_skills", () => { ]); }); - it("omits --pin when the resolved skill spec is unpinned", () => { - expect(script.buildSkillInstallCommand("githubnext/skills/review/security", "/tmp/gh-aw/.claude/skills").args).toEqual(["skill", "install", "githubnext/skills", "review/security", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]); + it("treats specs without @ as local path refs (installs with --from-local)", () => { + // Any spec without "@" and without "${{" is a local path reference. + // Remote refs must always be pinned (owner/repo@sha). + expect(script.buildSkillInstallCommand("skills/review/security", "/tmp/gh-aw/.claude/skills").args).toEqual(["skill", "install", "skills/review/security", "security", "--from-local", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]); + }); + + it("installs local path reference using --from-local", () => { + expect(script.buildSkillInstallCommand("skills/rig", "/tmp/gh-aw/.claude/skills").args).toEqual(["skill", "install", "skills/rig", "rig", "--from-local", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]); + expect(script.buildSkillInstallCommand(".github/skills/my-skill", "/tmp/gh-aw/.claude/skills", "claude-code").args).toEqual([ + "skill", + "install", + ".github/skills/my-skill", + "my-skill", + "--from-local", + "--agent", + "claude-code", + "--dir", + "/tmp/gh-aw/.claude/skills", + "--force", + ]); + expect(script.buildSkillInstallCommand("./skills/my-skill", "/tmp/gh-aw/.claude/skills").args).toEqual(["skill", "install", "./skills/my-skill", "my-skill", "--from-local", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]); + }); + + it("isLocalSkillRef returns true for local paths and false for remote or expression specs", () => { + expect(script.isLocalSkillRef("skills/rig")).toBe(true); + expect(script.isLocalSkillRef(".github/skills/my-skill")).toBe(true); + expect(script.isLocalSkillRef("./skills/my-skill")).toBe(true); + expect(script.isLocalSkillRef("my-skill")).toBe(true); + expect(script.isLocalSkillRef("owner/repo@abc123")).toBe(false); + expect(script.isLocalSkillRef("${{ inputs.skill_ref }}")).toBe(false); + expect(script.isLocalSkillRef("")).toBe(false); }); it("reads skill specs from the env var and installs them at runtime", async () => { @@ -148,4 +177,14 @@ describe("install_frontmatter_skills", () => { expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("Failed to install skill 'bad/repo@abc123'")); expect(global.core.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("
")); }); + + it("installs local path references using --from-local at runtime", async () => { + process.env.GH_AW_SKILL_DIR = ".claude/skills"; + process.env.GH_AW_GH_SKILL_AGENT_NAME = "claude-code"; + process.env.GH_AW_FRONTMATTER_SKILLS = "skills/rig"; + + await script.main(); + + expect(global.exec.exec).toHaveBeenCalledWith("gh", ["skill", "install", "skills/rig", "rig", "--from-local", "--agent", "claude-code", "--dir", "/tmp/gh-aw/.claude/skills", "--force"]); + }); }); diff --git a/docs/src/content/docs/reference/faq.md b/docs/src/content/docs/reference/faq.md index b792c4a2fa4..68f5380a800 100644 --- a/docs/src/content/docs/reference/faq.md +++ b/docs/src/content/docs/reference/faq.md @@ -185,7 +185,7 @@ See [Using MCPs](/gh-aw/guides/mcps/). ### If my agent can use a skill, can agentic workflows use it too? -Usually yes. Prefer frontmatter [`skills:`](/gh-aw/reference/frontmatter/#frontmatter-skills-skills) to install external skills for workflow runs. Use [imports](/gh-aw/reference/imports/) for workflow-level config and prompts, and [APM (Agent Package Manager)](https://microsoft.github.io/apm/) for reusable package distribution of skills and other agent primitives. See [APM Dependencies](/gh-aw/reference/dependencies/). +Usually yes. Prefer frontmatter [`skills:`](/gh-aw/reference/frontmatter/#frontmatter-skills-skills) to install skills for workflow runs: use local paths (for example, `skills/name` or `.github/skills/name`) during development and pinned external references for published workflows. Use [imports](/gh-aw/reference/imports/) for workflow-level config and prompts, and [APM (Agent Package Manager)](https://microsoft.github.io/apm/) for reusable package distribution of skills and other agent primitives. See [APM Dependencies](/gh-aw/reference/dependencies/). ### The `plugins:` or `dependencies:` field I was using is gone - how do I install agent plugins now? diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 1dcff4884a2..254a690238f 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -59,11 +59,12 @@ tracker-id: "example-value" labels: [] # Array of strings -# 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. Entries may also be objects to +# Optional list of skill references to install during activation. +# Supports local development paths (`skills/name`, `.github/skills/name`), +# repository-wide installs (`owner/repo@`), and path-scoped installs +# (`owner/repo/skill/path@`). Static external references must be pinned to +# a full 40-character lowercase commit SHA. GitHub Actions expressions (`${{ ... }}`) +# are also accepted and are evaluated at runtime. Entries may also be objects to # configure per-skill authentication via github-token or github-app. # (optional) skills: [] diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index ff5757ba15f..87f44a77183 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -203,12 +203,12 @@ See [Tools](/gh-aw/reference/tools/) for complete documentation on built-in tool ### Frontmatter Skills (`skills:`) -Installs external Copilot skills in the activation job before the agent runs. -Each entry must be pinned to a 40-character lowercase commit SHA. +Installs Copilot skills in the activation job before the agent runs. Supported entry formats: - String form (shared authentication): + - `skills/name` or `.github/skills/name` (local development path; installed with `--from-local`) - `owner/repo@<40-char-sha>` - `owner/repo/skill/path@<40-char-sha>` - Object form (per-skill authentication): @@ -219,6 +219,7 @@ Supported entry formats: `github-token` and `github-app` are mutually exclusive for each object entry. `github-token` must be an expression such as `${{ secrets.NAME }}` or `${{ needs.auth.outputs.token }}`. +Static external references must be pinned to a 40-character lowercase commit SHA. ```yaml wrap skills: diff --git a/docs/src/content/docs/reference/glossary.md b/docs/src/content/docs/reference/glossary.md index 3070552c315..579372f7a0a 100644 --- a/docs/src/content/docs/reference/glossary.md +++ b/docs/src/content/docs/reference/glossary.md @@ -1050,11 +1050,11 @@ Markdown files with YAML frontmatter stored in `.github/skills/` for repository- ### Frontmatter Skills (`skills:`) -A frontmatter field that declares external skill repositories to install in the activation job before the agent runs. Each entry is a skill specification string (e.g., `owner/repo`, `owner/repo/path@sha`) pointing to a `.github/skills/` skill directory. The activation job installs each skill using the `gh skill install` command. When a skill fails to install, the failure is captured in the agent failure context and surfaces in failure issue/comment reports. Requires a recent version of the `gh` CLI. See [Skill Install Failure](#skill-install-failure) for error handling. +A frontmatter field that declares skills to install in the activation job before the agent runs. Entries can be local development paths (for example, `skills/name` or `.github/skills/name`) or external skill specs (for example, `owner/repo` or `owner/repo/path@sha`) pointing to a `.github/skills/` skill directory. Local paths install via `gh skill install ... --from-local`, while static external references must be pinned to a full 40-character lowercase commit SHA. When a skill fails to install, the failure is captured in the agent failure context and surfaces in failure issue/comment reports. Requires a recent version of the `gh` CLI. See [Skill Install Failure](#skill-install-failure) for error handling. ### Skill Install Failure -A failure category reported when one or more frontmatter skills could not be installed before the agent ran. Triggered by invalid skill references, inaccessible repositories, insufficient token permissions, or unsupported `gh` CLI versions. When skill install failures occur, they are captured by the `collect-skill-install-failures` activation step and included in the agent failure issue or comment via the `{skill_install_failure_context}` template. Resolve by verifying the skill reference format (`owner/repo` or `owner/repo/skill/path@sha`), confirming the token has read access to the skill repository, and ensuring a recent `gh` CLI version is available. See [Safe Outputs Reference](/gh-aw/reference/safe-outputs/). +A failure category reported when one or more frontmatter skills could not be installed before the agent ran. Triggered by invalid skill references, inaccessible repositories, insufficient token permissions, or unsupported `gh` CLI versions. When skill install failures occur, they are captured by the `collect-skill-install-failures` activation step and included in the agent failure issue or comment via the `{skill_install_failure_context}` template. Resolve by verifying the skill reference format (local path such as `skills/name` or external reference such as `owner/repo` / `owner/repo/skill/path@sha`), confirming the token has read access to external skill repositories when applicable, and ensuring a recent `gh` CLI version is available. See [Safe Outputs Reference](/gh-aw/reference/safe-outputs/). ### Fine-grained Personal Access Token diff --git a/pkg/cli/audit_test.go b/pkg/cli/audit_test.go index 4dc0a88a679..ccf46827fdc 100644 --- a/pkg/cli/audit_test.go +++ b/pkg/cli/audit_test.go @@ -545,6 +545,10 @@ func TestAuditCachingBehavior(t *testing.T) { t.Errorf("Expected workflow name %s, got %s", summary.Run.WorkflowName, loadedSummary.Run.WorkflowName) } + if err := markArtifactDownloaded(runOutputDir, string(ArtifactSetAll)); err != nil { + t.Fatalf("markArtifactDownloaded: %v", err) + } + // Verify that downloadRunArtifacts skips download when valid summary exists // This is tested by checking that the function returns without error // and doesn't attempt to call `gh run download` diff --git a/pkg/cli/data/agentic_workflows_fallback_aw_files.json b/pkg/cli/data/agentic_workflows_fallback_aw_files.json index ba497b7d5cc..3d02da604cb 100644 --- a/pkg/cli/data/agentic_workflows_fallback_aw_files.json +++ b/pkg/cli/data/agentic_workflows_fallback_aw_files.json @@ -19,6 +19,7 @@ "evals.md", "experiments.md", "github-agentic-workflows.md", + "github-mcp-server-pagination.md", "github-mcp-server.md", "instructions.md", "linter-workflows.md", @@ -48,10 +49,12 @@ "subagents.md", "syntax-agentic.md", "syntax-core.md", + "syntax-engine.md", "syntax-tools-imports.md", "syntax.md", "test-coverage.md", "test-expression.md", + "token-optimization-caching-budgets.md", "token-optimization.md", "triggers.md", "update-agentic-workflow.md", diff --git a/pkg/cli/view_command_test.go b/pkg/cli/view_command_test.go index 50c8d68382d..71b558f1ff1 100644 --- a/pkg/cli/view_command_test.go +++ b/pkg/cli/view_command_test.go @@ -121,9 +121,10 @@ func buildViewRunDir(t *testing.T) string { t.Fatalf("WriteFile events.jsonl: %v", err) } - // Mark the directory as already downloaded so downloadRunArtifacts skips - // network calls (it returns early when the dir is non-empty and has no cached - // summary — it just skips the download and lets the caller process what's there). + // Mark all artifacts as downloaded so downloadRunArtifacts skips network calls. + if err := markArtifactDownloaded(runDir, string(ArtifactSetAll)); err != nil { + t.Fatalf("markArtifactDownloaded: %v", err) + } return dir } @@ -272,17 +273,15 @@ func TestViewWorkflowRun_WithSafeOutputs_ShowsSection(t *testing.T) { } func TestViewWorkflowRun_EmptyDir_WarnsAndReturnsNil(t *testing.T) { - // A run dir that is non-empty (so downloadRunArtifacts skips the network call) - // but contains no JSONL files → no events → warning, no error. + // A run dir marked as downloaded, but containing no JSONL files + // -> no events -> warning, no error. logsDir := t.TempDir() runDir := filepath.Join(logsDir, "run-1111") if err := os.MkdirAll(runDir, 0755); err != nil { t.Fatalf("MkdirAll: %v", err) } - // Place a dummy file so the directory is not empty; downloadRunArtifacts will - // skip the download when the dir is non-empty (no valid cached summary). - if err := os.WriteFile(filepath.Join(runDir, "placeholder.txt"), []byte("x"), 0600); err != nil { - t.Fatalf("WriteFile placeholder: %v", err) + if err := markArtifactDownloaded(runDir, string(ArtifactSetAll)); err != nil { + t.Fatalf("markArtifactDownloaded: %v", err) } opts := ViewOptions{ diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 22bcfb1d573..679c31c4013 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -58,7 +58,7 @@ }, "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. Entries may also be objects to configure per-skill authentication via github-token or github-app.", + "description": "Optional list of skill references to install during activation. Supports remote repository-wide installs (`owner/repo@`), remote path-scoped installs (`owner/repo/skill/path@`), and local path references (e.g. `skills/rig` or `.github/skills/my-skill`). Remote static references must be pinned to a full 40-character lowercase commit SHA. Local paths are installed with --from-local at runtime and are rewritten to a remote repospec by `gh aw add`. GitHub Actions expressions (`${{ ... }}`) are also accepted and are evaluated at runtime. Entries may also be objects to configure per-skill authentication via github-token or github-app.", "items": { "oneOf": [ { @@ -75,6 +75,11 @@ "pattern": "^\\$\\{\\{.+\\}\\}$", "description": "GitHub Actions expression that resolves to a full skill reference string at runtime." }, + { + "type": "string", + "pattern": "^(?:\\./)?(?:\\.[A-Za-z0-9_-][A-Za-z0-9_.-]*|[A-Za-z0-9_-][A-Za-z0-9_.-]*)(?:\\/(?:\\.[A-Za-z0-9_-][A-Za-z0-9_.-]*|[A-Za-z0-9_-][A-Za-z0-9_.-]*))*$", + "description": "Local path to a skill directory relative to the repository root. Installed with --from-local at runtime. Rewritten to a remote repospec by gh aw add." + }, { "type": "object", "description": "Object-form skill reference with per-skill authentication.", @@ -94,6 +99,11 @@ { "type": "string", "pattern": "^\\$\\{\\{.+\\}\\}$" + }, + { + "type": "string", + "pattern": "^(?:\\./)?(?:\\.[A-Za-z0-9_-][A-Za-z0-9_.-]*|[A-Za-z0-9_-][A-Za-z0-9_.-]*)(?:\\/(?:\\.[A-Za-z0-9_-][A-Za-z0-9_.-]*|[A-Za-z0-9_-][A-Za-z0-9_.-]*))*$", + "description": "Local path to a skill directory relative to the repository root." } ], "description": "Skill reference string in the same format accepted by string-form entries." @@ -110,7 +120,7 @@ } ] }, - "examples": [["githubnext/awesome-skills@1f181b37d3fe5862ab590648f25a292e345b5de6"], ["githubnext/awesome-skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6"]] + "examples": [["githubnext/awesome-skills@1f181b37d3fe5862ab590648f25a292e345b5de6"], ["githubnext/awesome-skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6"], ["skills/rig"]] }, "metadata": { "type": "object", diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 7c8e58886f7..fefe91d6558 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -440,6 +440,11 @@ func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData) extraPaths = append(extraPaths, folder) } } + for _, folder := range localSkillSparseCheckoutTopLevelDirs(data) { + if !setutil.Contains(defaultSparseCheckoutDirs, folder) { + extraPaths = append(extraPaths, folder) + } + } compilerActivationJobLog.Printf("Adding %d engine-specific dirs to sparse-checkout: %v", len(extraPaths), extraPaths) // Detect symlinks for well-known .github sub-paths and add their resolved targets @@ -486,6 +491,62 @@ func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData) return cm.GenerateGitHubFolderCheckoutStep("", "", activationToken, c.getActionPin, extraPaths...) } +func localSkillSparseCheckoutTopLevelDirs(data *WorkflowData) []string { + if data == nil { + return nil + } + refs := append([]SkillReference(nil), data.SkillReferences...) + if len(refs) == 0 && len(data.Skills) > 0 { + refs = make([]SkillReference, 0, len(data.Skills)) + for _, skill := range data.Skills { + skill = strings.TrimSpace(skill) + if skill == "" { + continue + } + refs = append(refs, SkillReference{Skill: skill}) + } + } + if len(refs) == 0 { + return nil + } + + seen := map[string]struct{}{} + result := make([]string, 0, len(refs)) + for _, ref := range refs { + spec := strings.TrimSpace(ref.Skill) + if !isLocalSkillRef(spec) { + continue + } + normalized := strings.TrimPrefix(strings.ReplaceAll(spec, "\\", "/"), "./") + if normalized == "" { + continue + } + parts := strings.Split(normalized, "/") + if len(parts) == 0 { + continue + } + invalid := false + for _, part := range parts { + if part == "" || part == "." || part == ".." { + invalid = true + break + } + } + if invalid { + continue + } + + topLevel := parts[0] + if _, ok := seen[topLevel]; ok { + continue + } + seen[topLevel] = struct{}{} + result = append(result, topLevel) + } + + return result +} + // addSameRepoIfConditionToSteps injects an if: condition into each step that restricts // execution to same-repo workflow_call invocations. This prevents checkout steps from // failing when GITHUB_TOKEN cannot read a private callee repository in cross-repo scenarios. diff --git a/pkg/workflow/compiler_activation_job_test.go b/pkg/workflow/compiler_activation_job_test.go index 77030dd7aeb..39e8fdf3951 100644 --- a/pkg/workflow/compiler_activation_job_test.go +++ b/pkg/workflow/compiler_activation_job_test.go @@ -813,6 +813,45 @@ func TestGenerateCheckoutGitHubFolderForActivation_ActionsModeSetupPath(t *testi } } +func TestGenerateCheckoutGitHubFolderForActivation_LocalSkillSparseCheckout(t *testing.T) { + c := NewCompiler(WithVersion("dev")) + c.SetActionMode(ActionModeRelease) + data := &WorkflowData{ + On: `"on": + issues: + types: [opened]`, + SkillReferences: []SkillReference{ + {Skill: "skills/rig"}, + {Skill: "./skills/another"}, + {Skill: ".github/skills/infra"}, + {Skill: "githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6"}, + }, + } + + steps := c.generateCheckoutGitHubFolderForActivation(data) + combined := strings.Join(steps, "") + + assert.Contains(t, combined, "\n skills\n", "local skill dirs outside .github/.agents should be included in sparse checkout") + assert.Equal(t, 1, strings.Count(combined, "\n skills\n"), "top-level local skill dir should not be duplicated") + assert.Contains(t, combined, "\n .github\n", ".github remains in sparse checkout by default") +} + +func TestLocalSkillSparseCheckoutTopLevelDirs(t *testing.T) { + data := &WorkflowData{ + SkillReferences: []SkillReference{ + {Skill: "skills/rig"}, + {Skill: "./skills/another"}, + {Skill: ".github/skills/infra"}, + {Skill: "team-skills"}, + {Skill: "skills/../bad"}, + {Skill: "../outside"}, + {Skill: "githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6"}, + }, + } + + assert.Equal(t, []string{"skills", ".github", "team-skills"}, localSkillSparseCheckoutTopLevelDirs(data)) +} + // TestGenerateGitHubFolderCheckoutStep_ExtraPaths verifies that extraPaths are // correctly appended to the sparse-checkout list. func TestGenerateGitHubFolderCheckoutStep_ExtraPaths(t *testing.T) { diff --git a/pkg/workflow/skills_frontmatter.go b/pkg/workflow/skills_frontmatter.go index 6f575801925..e89ee07e7ac 100644 --- a/pkg/workflow/skills_frontmatter.go +++ b/pkg/workflow/skills_frontmatter.go @@ -12,8 +12,21 @@ import ( var skillsFrontmatterLog = logger.New("workflow:skills_frontmatter") 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 localSkillPathRegexp = regexp.MustCompile(`^(?:\./)?(?:\.[A-Za-z0-9_-][A-Za-z0-9_.-]*|[A-Za-z0-9_-][A-Za-z0-9_.-]*)(?:/(?:\.[A-Za-z0-9_-][A-Za-z0-9_.-]*|[A-Za-z0-9_-][A-Za-z0-9_.-]*))*$`) var skillsGitHubTokenExpressionRegexp = regexp.MustCompile(`^\$\{\{\s*(secrets\.[A-Za-z_][A-Za-z0-9_]*(\s*\|\|\s*secrets\.[A-Za-z_][A-Za-z0-9_]*)*|needs\.[A-Za-z_][A-Za-z0-9_]*\.outputs\.[A-Za-z_][A-Za-z0-9_]*)\s*\}\}$`) +// isLocalSkillRef reports whether spec is a local skill reference — a +// repository-relative path that should be installed with --from-local at +// runtime. A spec is treated as local when it: +// - is not empty, +// - does not begin with "${{" (not a GitHub Actions expression), and +// - contains no "@" separator (and therefore cannot be a fully-pinned +// remote reference such as "owner/repo/path@<40-char-sha>"). +func isLocalSkillRef(spec string) bool { + spec = strings.TrimSpace(spec) + return spec != "" && !strings.HasPrefix(spec, "${{") && !strings.Contains(spec, "@") +} + // SkillReference describes a single skills[] entry in workflow frontmatter. // It supports both legacy string-only entries and object entries with per-skill auth. type SkillReference struct { @@ -26,6 +39,20 @@ func validateSkillSpecValue(skillSpec string, idx int) error { if strings.TrimSpace(skillSpec) == "" { return fmt.Errorf("skills[%d] must be a non-empty string. Example: skills[%d]: \"owner/repo@abc1234...\"", idx, idx) } + // Local path references (no "@" and not an expression) are allowed; they + // are installed with --from-local at runtime and rewritten to a remote + // repospec by "gh aw add". + if isLocalSkillRef(skillSpec) { + if !localSkillPathRegexp.MatchString(strings.TrimSpace(skillSpec)) { + return fmt.Errorf( + "skills[%d] local paths must be repository-relative without '..' traversal segments (got %q). Example: skills[%d]: \"./skills/my-skill\"", + idx, + skillSpec, + idx, + ) + } + return nil + } if !skillSpecRegexp.MatchString(skillSpec) { return fmt.Errorf( "skills[%d] must use owner/repo@<40-char-sha> or owner/repo/skill/path@<40-char-sha> (got %q). Example: skills[%d]: \"owner/repo@abcdef1234567890abcdef1234567890abcdef12\"", diff --git a/pkg/workflow/skills_frontmatter_test.go b/pkg/workflow/skills_frontmatter_test.go index 20341036ea3..6b4e3f1799e 100644 --- a/pkg/workflow/skills_frontmatter_test.go +++ b/pkg/workflow/skills_frontmatter_test.go @@ -19,6 +19,39 @@ func TestValidateFrontmatterSkills(t *testing.T) { require.NoError(t, err) }) + t.Run("accepts local path references", func(t *testing.T) { + err := validateFrontmatterSkills(map[string]any{ + "skills": []any{ + "skills/rig", + ".github/skills/my-skill", + "./skills/my-skill", + }, + }) + require.NoError(t, err) + }) + + t.Run("accepts object form with local path skill", func(t *testing.T) { + err := validateFrontmatterSkills(map[string]any{ + "skills": []any{ + map[string]any{ + "skill": "skills/rig", + }, + }, + }) + require.NoError(t, err) + }) + + t.Run("rejects local path traversal segments", func(t *testing.T) { + err := validateFrontmatterSkills(map[string]any{ + "skills": []any{ + "skills/../rig", + "../skills/rig", + }, + }) + require.Error(t, err) + require.ErrorContains(t, err, "without '..' traversal segments") + }) + t.Run("rejects non-sha refs", func(t *testing.T) { err := validateFrontmatterSkills(map[string]any{ "skills": []any{