From e1cf3a442297b3a68ac46f34e014fda1d99078ff Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Mar 2026 19:01:15 +0000
Subject: [PATCH 01/49] Initial plan
From 16cd7f56b4b736f66ac84426791e2d669466413b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Mar 2026 19:37:05 +0000
Subject: [PATCH 02/49] Add builtin qmd documentation search tool support
- Add QmdToolConfig type with docs glob patterns field
- Add DefaultQmdVersion (0.0.16) and QmdArtifactName constants
- Add parseQmdTool parser function
- Register qmd as builtin tool in tools_parser, tools_types, mcp_config_validation
- Activation job: install @tobilu/qmd, build index, upload qmd-index artifact
- Agent job: download qmd-index artifact before MCP setup
- MCP renderer: RenderQmdMCP for JSON/TOML (Node.js + npx serve-mcp)
- MCP setup generator: qmd added to standard MCP tools list
- Claude tools: qmd handled as wildcard MCP server (like serena)
- Docker: add node:lts-alpine pre-pull when qmd is configured
- System prompt: qmd_prompt.md injected when qmd tool is active
- Dependabot: track @tobilu/qmd version in npm deps
- Shell injection prevention: single-quote glob patterns in activation steps
- JSON schema: add qmd tool schema definition
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e5f827de-afbd-4b2e-98e0-7a7d87ed547a
---
actions/setup/md/qmd_prompt.md | 15 ++
pkg/constants/constants.go | 6 +
pkg/parser/schemas/main_workflow_schema.json | 21 +++
pkg/workflow/claude_tools.go | 9 +-
pkg/workflow/codex_mcp.go | 3 +
pkg/workflow/compiler_activation_job.go | 9 ++
.../compiler_orchestrator_workflow.go | 5 +
pkg/workflow/compiler_types.go | 1 +
pkg/workflow/compiler_yaml_main_job.go | 7 +
pkg/workflow/dependabot.go | 7 +
pkg/workflow/docker.go | 10 ++
pkg/workflow/mcp_config_validation.go | 1 +
pkg/workflow/mcp_renderer.go | 5 +
pkg/workflow/mcp_renderer_builtin.go | 86 +++++++++++
pkg/workflow/mcp_renderer_helpers.go | 3 +
pkg/workflow/mcp_renderer_types.go | 1 +
pkg/workflow/mcp_setup_generator.go | 2 +-
pkg/workflow/prompt_constants.go | 1 +
pkg/workflow/qmd.go | 134 ++++++++++++++++++
pkg/workflow/tools_parser.go | 37 ++++-
pkg/workflow/tools_types.go | 19 +++
pkg/workflow/unified_prompt_step.go | 9 ++
22 files changed, 384 insertions(+), 7 deletions(-)
create mode 100644 actions/setup/md/qmd_prompt.md
create mode 100644 pkg/workflow/qmd.go
diff --git a/actions/setup/md/qmd_prompt.md b/actions/setup/md/qmd_prompt.md
new file mode 100644
index 00000000000..77af4d49bbb
--- /dev/null
+++ b/actions/setup/md/qmd_prompt.md
@@ -0,0 +1,15 @@
+
+Use the qmd search tool to find relevant documentation files using vector similarity — it queries a local index built from the configured documentation globs. Read the returned file paths to get full content.
+
+**Always use the qmd search tool first** when you need to find, verify, or search documentation:
+- **Before using `find` or `bash` to list files** — use qmd search to discover the most relevant docs for a topic
+- **Before writing new content** — search first to check whether documentation already exists
+- **When identifying relevant files** — use it to narrow down which documentation pages cover a feature or concept
+- **When understanding a term or concept** — query to find authoritative documentation describing it
+
+**Usage tips:**
+- Use descriptive, natural language queries: e.g., `"how to configure MCP servers"` or `"safe-outputs create-pull-request options"` or `"permissions frontmatter field"`
+- Always read the returned file paths to get the full content — the qmd search tool returns paths only, not content
+- Combine multiple targeted queries rather than one broad query for better coverage
+- A lower score threshold gives broader results; a higher one (e.g., `0.6`) returns only the most closely matching files
+
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index ff3fd18da9a..faf09e0ef61 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -412,6 +412,9 @@ const DefaultAPMVersion Version = "v0.8.2"
// DefaultPlaywrightMCPVersion is the default version of the @playwright/mcp package
const DefaultPlaywrightMCPVersion Version = "0.0.68"
+// DefaultQmdVersion is the default version of the @tobilu/qmd npm package
+const DefaultQmdVersion Version = "0.0.16"
+
// DefaultPlaywrightBrowserVersion is the default version of the Playwright browser Docker image
const DefaultPlaywrightBrowserVersion Version = "v1.58.2"
@@ -648,6 +651,9 @@ const ActivationArtifactName = "activation"
// APMArtifactName is the artifact name for the APM (Agent Package Manager) bundle.
const APMArtifactName = "apm"
+// QmdArtifactName is the artifact name for the qmd documentation index built in the activation job.
+const QmdArtifactName = "qmd-index"
+
// SafeOutputItemsArtifactName is the artifact name for the safe output items manifest.
// This artifact contains the JSONL manifest of all items created by safe output handlers
// and is uploaded by the safe_outputs job to avoid conflicting with the "agent" artifact
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 7c5f5cf5acd..691470f48a8 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -3359,6 +3359,27 @@
],
"examples": [true, null]
},
+ "qmd": {
+ "description": "qmd documentation search tool (https://github.com/tobi/qmd). Builds a local vector search index of documentation files during the activation job and mounts a search MCP server in the agent job. The agent job does not need contents:read permission since the index is pre-built.",
+ "type": "object",
+ "properties": {
+ "docs": {
+ "type": "array",
+ "description": "List of glob patterns for documentation files to include in the search index.",
+ "items": {
+ "type": "string"
+ },
+ "examples": [["docs/**/*.md", ".github/**/*.md"]]
+ }
+ },
+ "required": ["docs"],
+ "additionalProperties": false,
+ "examples": [
+ {
+ "docs": ["docs/**/*.md", ".github/**/*.md"]
+ }
+ ]
+ },
"cache-memory": {
"description": "Cache memory MCP configuration for persistent memory storage",
"oneOf": [
diff --git a/pkg/workflow/claude_tools.go b/pkg/workflow/claude_tools.go
index 176d2670ebe..10fb6110718 100644
--- a/pkg/workflow/claude_tools.go
+++ b/pkg/workflow/claude_tools.go
@@ -340,11 +340,10 @@ func (e *ClaudeEngine) computeAllowedClaudeToolsString(tools map[string]any, saf
allowedTools = append(allowedTools, "mcp__github__"+defaultTool)
}
}
- } else if toolName == "serena" {
- // Serena uses a language-based config (not standard MCP type/url/command fields),
- // so hasMCPConfig returns false. Add the server wildcard so Claude can use all
- // Serena tools (find_symbol, activate_project, etc.).
- allowedTools = append(allowedTools, "mcp__serena")
+ } else if toolName == "serena" || toolName == "qmd" {
+ // Serena and qmd use non-standard config shapes (not standard MCP type/url/command fields),
+ // so hasMCPConfig returns false. Add the server wildcard so Claude can use all tools.
+ allowedTools = append(allowedTools, "mcp__"+toolName)
} else if toolName == "playwright" || isCustomMCP {
// Handle playwright and custom MCP tools with generic parsing
if allowed, hasAllowed := mcpConfig["allowed"]; hasAllowed {
diff --git a/pkg/workflow/codex_mcp.go b/pkg/workflow/codex_mcp.go
index 0ca76a52eac..9e045fd7de9 100644
--- a/pkg/workflow/codex_mcp.go
+++ b/pkg/workflow/codex_mcp.go
@@ -52,6 +52,9 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an
case "playwright":
playwrightTool := expandedTools["playwright"]
renderer.RenderPlaywrightMCP(yaml, playwrightTool)
+ case "qmd":
+ qmdTool := expandedTools["qmd"]
+ renderer.RenderQmdMCP(yaml, qmdTool)
case "serena":
serenaTool := expandedTools["serena"]
renderer.RenderSerenaMCP(yaml, serenaTool)
diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go
index 6f192029c34..230e34fa1d2 100644
--- a/pkg/workflow/compiler_activation_job.go
+++ b/pkg/workflow/compiler_activation_job.go
@@ -485,6 +485,15 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
}
}
+ // Generate qmd index steps if the qmd tool is configured.
+ // The index is built here in the activation job (which has contents:read) so the agent job
+ // does not need contents:read permission to search documentation.
+ if data.QmdConfig != nil {
+ compilerActivationJobLog.Printf("Adding qmd index build steps: docs=%v", data.QmdConfig.Docs)
+ qmdSteps := generateQmdIndexSteps(data.QmdConfig, data)
+ steps = append(steps, qmdSteps...)
+ }
+
// Upload aw_info.json and prompt.txt as the activation artifact for the agent job to download.
// In workflow_call context the artifact is prefixed to avoid name clashes when multiple callers
// invoke the same reusable workflow within the same parent workflow run.
diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go
index 58cec6aab9b..8f4ee08638f 100644
--- a/pkg/workflow/compiler_orchestrator_workflow.go
+++ b/pkg/workflow/compiler_orchestrator_workflow.go
@@ -721,6 +721,11 @@ func (c *Compiler) extractAdditionalConfigurations(
}
workflowData.RepoMemoryConfig = repoMemoryConfig
+ // Extract qmd config from parsed tools
+ if toolsConfig.Qmd != nil {
+ workflowData.QmdConfig = toolsConfig.Qmd
+ }
+
// Extract and process mcp-scripts and safe-outputs
workflowData.Command, workflowData.CommandEvents = c.extractCommandConfig(frontmatter)
workflowData.LabelCommand, workflowData.LabelCommandEvents, workflowData.LabelCommandRemoveLabel = c.extractLabelCommandConfig(frontmatter)
diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go
index c5d4041d9a5..e2283fb86c9 100644
--- a/pkg/workflow/compiler_types.go
+++ b/pkg/workflow/compiler_types.go
@@ -403,6 +403,7 @@ type WorkflowData struct {
RateLimit *RateLimitConfig // rate limiting configuration for workflow triggers
CacheMemoryConfig *CacheMemoryConfig // parsed cache-memory configuration
RepoMemoryConfig *RepoMemoryConfig // parsed repo-memory configuration
+ QmdConfig *QmdToolConfig // parsed qmd tool configuration (docs globs)
Runtimes map[string]any // runtime version overrides from frontmatter
APMDependencies *APMDependenciesInfo // APM (Agent Package Manager) dependency packages to install
ToolsTimeout int // timeout in seconds for tool/MCP operations (0 = use engine default)
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index 6f99154f9d8..aa527faf54f 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -279,6 +279,13 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
}
}
+ // Download qmd index artifact if qmd tool is configured.
+ // The index was built in the activation job to avoid needing contents:read in the agent job.
+ if data.QmdConfig != nil {
+ compilerYamlLog.Print("Adding qmd index download step")
+ yaml.WriteString(generateQmdDownloadStep(data))
+ }
+
// GH_AW_SAFE_OUTPUTS is now set at job level, no setup step needed
// Add GitHub MCP lockdown detection step if needed
diff --git a/pkg/workflow/dependabot.go b/pkg/workflow/dependabot.go
index cdf29c95ef9..b257b873eb0 100644
--- a/pkg/workflow/dependabot.go
+++ b/pkg/workflow/dependabot.go
@@ -13,6 +13,7 @@ import (
"strings"
"github.com/github/gh-aw/pkg/console"
+ "github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/logger"
"github.com/goccy/go-yaml"
)
@@ -175,6 +176,12 @@ func (c *Compiler) collectNpmDependencies(workflowDataList []*WorkflowData) []Np
dep := parseNpmPackage(pkg)
depMap[dep.Name] = dep.Version
}
+
+ // Track qmd builtin package version when qmd tool is configured
+ if workflowData.QmdConfig != nil {
+ depMap["@tobilu/qmd"] = string(constants.DefaultQmdVersion)
+ dependabotLog.Print("Added @tobilu/qmd builtin package to npm dependencies")
+ }
}
// Convert map to sorted slice
diff --git a/pkg/workflow/docker.go b/pkg/workflow/docker.go
index c1a3ae10bd6..b7645b19c71 100644
--- a/pkg/workflow/docker.go
+++ b/pkg/workflow/docker.go
@@ -63,6 +63,16 @@ func collectDockerImages(tools map[string]any, workflowData *WorkflowData, actio
}
}
+ // Check for qmd tool (uses node:lts-alpine container for the MCP server)
+ if _, hasQmd := tools["qmd"]; hasQmd {
+ image := constants.DefaultNodeAlpineLTSImage
+ if !imageSet[image] {
+ images = append(images, image)
+ imageSet[image] = true
+ dockerLog.Printf("Added qmd MCP server container: %s", image)
+ }
+ }
+
// Check for agentic-workflows tool
// In dev mode, the image is built locally in the workflow, so don't add to pull list
// In release/script mode, use alpine:latest which needs to be pulled
diff --git a/pkg/workflow/mcp_config_validation.go b/pkg/workflow/mcp_config_validation.go
index d8e03e69c5c..fab2aebacb0 100644
--- a/pkg/workflow/mcp_config_validation.go
+++ b/pkg/workflow/mcp_config_validation.go
@@ -64,6 +64,7 @@ func ValidateMCPConfigs(tools map[string]any) error {
builtInTools := map[string]bool{
"github": true,
"playwright": true,
+ "qmd": true,
"serena": true,
"agentic-workflows": true,
"cache-memory": true,
diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go
index 2fc03bdec2f..bb8408199d5 100644
--- a/pkg/workflow/mcp_renderer.go
+++ b/pkg/workflow/mcp_renderer.go
@@ -144,6 +144,11 @@ func RenderJSONMCPConfig(
case "playwright":
playwrightTool := tools["playwright"]
options.Renderers.RenderPlaywright(&configBuilder, playwrightTool, isLast)
+ case "qmd":
+ qmdTool := tools["qmd"]
+ if options.Renderers.RenderQmd != nil {
+ options.Renderers.RenderQmd(&configBuilder, qmdTool, isLast)
+ }
case "serena":
serenaTool := tools["serena"]
options.Renderers.RenderSerena(&configBuilder, serenaTool, isLast)
diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go
index ee0777faddc..0ac035580d9 100644
--- a/pkg/workflow/mcp_renderer_builtin.go
+++ b/pkg/workflow/mcp_renderer_builtin.go
@@ -73,6 +73,92 @@ func (r *MCPConfigRendererUnified) renderPlaywrightTOML(yaml *strings.Builder, p
yaml.WriteString(" mounts = [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"]\n")
}
+// RenderQmdMCP generates the qmd documentation search MCP server configuration.
+// qmd uses a Node.js container running @tobilu/qmd serve-mcp and mounts the pre-built index
+// that was downloaded from the activation job artifact.
+func (r *MCPConfigRendererUnified) RenderQmdMCP(yaml *strings.Builder, qmdTool any) {
+ mcpRendererLog.Printf("Rendering qmd MCP: format=%s, inline_args=%t", r.options.Format, r.options.InlineArgs)
+
+ if r.options.Format == "toml" {
+ r.renderQmdTOML(yaml)
+ return
+ }
+
+ // JSON format
+ renderQmdMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs)
+}
+
+// renderQmdTOML generates qmd MCP configuration in TOML format.
+// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized.
+func (r *MCPConfigRendererUnified) renderQmdTOML(yaml *strings.Builder) {
+ mcpRendererBuiltinLog.Print("Rendering qmd MCP in TOML format")
+
+ yaml.WriteString(" \n")
+ yaml.WriteString(" [mcp_servers.qmd]\n")
+ yaml.WriteString(" container = \"" + string(constants.DefaultNodeAlpineLTSImage) + "\"\n")
+
+ // Entrypoint for qmd MCP server
+ yaml.WriteString(" entrypoint = \"npx\"\n")
+ yaml.WriteString(" entrypointArgs = [\n")
+ yaml.WriteString(" \"@tobilu/qmd@" + string(constants.DefaultQmdVersion) + "\",\n")
+ yaml.WriteString(" \"serve-mcp\",\n")
+ yaml.WriteString(" ]\n")
+
+ // Mount the pre-built index (downloaded from activation artifact)
+ yaml.WriteString(" mounts = [\"/tmp/gh-aw/qmd-index:/tmp/gh-aw/qmd-index:ro\"]\n")
+ yaml.WriteString(" env_vars = [\"QMD_CACHE_DIR\"]\n")
+}
+
+// renderQmdMCPConfigWithOptions generates the qmd MCP server configuration in JSON format.
+func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, inlineArgs bool) {
+ yaml.WriteString(" \"qmd\": {\n")
+
+ if includeCopilotFields {
+ yaml.WriteString(" \"type\": \"stdio\",\n")
+ }
+
+ yaml.WriteString(" \"container\": \"" + string(constants.DefaultNodeAlpineLTSImage) + "\",\n")
+ yaml.WriteString(" \"entrypoint\": \"npx\",\n")
+
+ entrypointArgs := []string{
+ "@tobilu/qmd@" + string(constants.DefaultQmdVersion),
+ "serve-mcp",
+ }
+
+ if inlineArgs {
+ yaml.WriteString(" \"entrypointArgs\": [")
+ for i, arg := range entrypointArgs {
+ if i > 0 {
+ yaml.WriteString(", ")
+ }
+ yaml.WriteString("\"" + arg + "\"")
+ }
+ yaml.WriteString("],\n")
+ } else {
+ yaml.WriteString(" \"entrypointArgs\": [\n")
+ for i, arg := range entrypointArgs {
+ yaml.WriteString(" \"" + arg + "\"")
+ if i < len(entrypointArgs)-1 {
+ yaml.WriteString(",")
+ }
+ yaml.WriteString("\n")
+ }
+ yaml.WriteString(" ],\n")
+ }
+
+ // Mount the pre-built index read-only; pass QMD_CACHE_DIR so the server finds it
+ yaml.WriteString(" \"mounts\": [\"/tmp/gh-aw/qmd-index:/tmp/gh-aw/qmd-index:ro\"],\n")
+ yaml.WriteString(" \"env\": {\n")
+ yaml.WriteString(" \"QMD_CACHE_DIR\": \"/tmp/gh-aw/qmd-index\"\n")
+ yaml.WriteString(" }\n")
+
+ if isLast {
+ yaml.WriteString(" }\n")
+ } else {
+ yaml.WriteString(" },\n")
+ }
+}
+
// RenderSerenaMCP generates Serena MCP server configuration
func (r *MCPConfigRendererUnified) RenderSerenaMCP(yaml *strings.Builder, serenaTool any) {
mcpRendererLog.Printf("Rendering Serena MCP: format=%s, inline_args=%t", r.options.Format, r.options.InlineArgs)
diff --git a/pkg/workflow/mcp_renderer_helpers.go b/pkg/workflow/mcp_renderer_helpers.go
index d2419183c2c..573b96b3078 100644
--- a/pkg/workflow/mcp_renderer_helpers.go
+++ b/pkg/workflow/mcp_renderer_helpers.go
@@ -78,6 +78,9 @@ func buildStandardJSONMCPRenderers(
RenderPlaywright: func(yaml *strings.Builder, playwrightTool any, isLast bool) {
createRenderer(isLast).RenderPlaywrightMCP(yaml, playwrightTool)
},
+ RenderQmd: func(yaml *strings.Builder, qmdTool any, isLast bool) {
+ createRenderer(isLast).RenderQmdMCP(yaml, qmdTool)
+ },
RenderSerena: func(yaml *strings.Builder, serenaTool any, isLast bool) {
createRenderer(isLast).RenderSerenaMCP(yaml, serenaTool)
},
diff --git a/pkg/workflow/mcp_renderer_types.go b/pkg/workflow/mcp_renderer_types.go
index 97a4803661a..18050eb0dce 100644
--- a/pkg/workflow/mcp_renderer_types.go
+++ b/pkg/workflow/mcp_renderer_types.go
@@ -34,6 +34,7 @@ type RenderCustomMCPToolConfigHandler func(yaml *strings.Builder, toolName strin
type MCPToolRenderers struct {
RenderGitHub func(yaml *strings.Builder, githubTool any, isLast bool, workflowData *WorkflowData)
RenderPlaywright func(yaml *strings.Builder, playwrightTool any, isLast bool)
+ RenderQmd func(yaml *strings.Builder, qmdTool any, isLast bool)
RenderSerena func(yaml *strings.Builder, serenaTool any, isLast bool)
RenderCacheMemory func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData)
RenderAgenticWorkflows func(yaml *strings.Builder, isLast bool)
diff --git a/pkg/workflow/mcp_setup_generator.go b/pkg/workflow/mcp_setup_generator.go
index f9cc9af6eec..77ba261fd38 100644
--- a/pkg/workflow/mcp_setup_generator.go
+++ b/pkg/workflow/mcp_setup_generator.go
@@ -95,7 +95,7 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any,
continue
}
// Standard MCP tools
- if toolName == "github" || toolName == "playwright" || toolName == "serena" || toolName == "cache-memory" || toolName == "agentic-workflows" {
+ if toolName == "github" || toolName == "playwright" || toolName == "qmd" || toolName == "serena" || toolName == "cache-memory" || toolName == "agentic-workflows" {
mcpTools = append(mcpTools, toolName)
} else if mcpConfig, ok := toolValue.(map[string]any); ok {
// Check if it's explicitly marked as MCP type in the new format
diff --git a/pkg/workflow/prompt_constants.go b/pkg/workflow/prompt_constants.go
index 642955b5927..a29a759da88 100644
--- a/pkg/workflow/prompt_constants.go
+++ b/pkg/workflow/prompt_constants.go
@@ -12,6 +12,7 @@ const (
prContextPromptFile = "pr_context_prompt.md"
tempFolderPromptFile = "temp_folder_prompt.md"
playwrightPromptFile = "playwright_prompt.md"
+ qmdPromptFile = "qmd_prompt.md"
markdownPromptFile = "markdown.md"
xpiaPromptFile = "xpia.md"
cacheMemoryPromptFile = "cache_memory_prompt.md"
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
new file mode 100644
index 00000000000..2b7b5aebdc6
--- /dev/null
+++ b/pkg/workflow/qmd.go
@@ -0,0 +1,134 @@
+// Package workflow provides qmd documentation search tool integration.
+//
+// # QMD Tool
+//
+// This file handles the qmd (https://github.com/tobi/qmd) builtin tool integration.
+// qmd provides local vector search over documentation files using the @tobilu/qmd npm package.
+//
+// The integration has two phases:
+//
+// 1. Activation job: builds the search index from configured doc globs and uploads it as
+// the "qmd-index" artifact. This step runs in the activation job which already has
+// contents:read permission, so the agent job does NOT need contents:read to search docs.
+//
+// 2. Agent job: downloads the "qmd-index" artifact and mounts the qmd MCP server pointing
+// at the pre-built index. The MCP server exposes a search tool that the agent can use
+// to find relevant documentation files.
+//
+// # Configuration
+//
+// Example frontmatter:
+//
+// tools:
+// qmd:
+// docs:
+// - docs/**/*.md
+// - .github/**/*.md
+//
+// # Artifact lifecycle
+//
+// The index is built once per activation job run and shared with the agent job
+// via the "qmd-index" artifact. Retention is 1 day (same as the activation artifact).
+//
+// Related files:
+// - tools_types.go: QmdToolConfig type
+// - tools_parser.go: parseQmdTool function
+// - mcp_renderer_builtin.go: RenderQmdMCP method
+// - compiler_activation_job.go: activation job qmd index steps
+// - compiler_yaml_main_job.go: agent job qmd artifact download
+
+package workflow
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/github/gh-aw/pkg/constants"
+ "github.com/github/gh-aw/pkg/logger"
+)
+
+var qmdLog = logger.New("workflow:qmd")
+
+// shellSingleQuote wraps s in POSIX single quotes, escaping any embedded single
+// quotes via the '"'"' idiom. The result is safe to interpolate directly into
+// a shell command: no shell metacharacters ($, `, \, ;, |, etc.) are
+// interpreted inside single-quoted strings.
+func shellSingleQuote(s string) string {
+ // Replace each ' with '\'' (end-quote, literal-apostrophe, re-open-quote)
+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
+}
+
+// hasQmdTool checks if the qmd tool is enabled in the tools configuration.
+func hasQmdTool(parsedTools *Tools) bool {
+ if parsedTools == nil {
+ return false
+ }
+ return parsedTools.Qmd != nil
+}
+
+// generateQmdIndexSteps generates the activation job steps that install qmd, register
+// collections for each configured doc glob, and build the vector search index.
+// The index is stored at /tmp/gh-aw/qmd-index and uploaded as the qmd-index artifact.
+func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []string {
+ qmdLog.Printf("Generating qmd index steps: docs=%v", qmdConfig.Docs)
+
+ version := string(constants.DefaultQmdVersion)
+ var steps []string
+
+ // Setup Node.js (required to run npm/npx)
+ steps = append(steps, " - name: Setup Node.js for qmd\n")
+ steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/setup-node")))
+ steps = append(steps, " with:\n")
+ steps = append(steps, fmt.Sprintf(" node-version: \"%s\"\n", string(constants.DefaultNodeVersion)))
+
+ // Install qmd globally
+ steps = append(steps, " - name: Install qmd\n")
+ steps = append(steps, " run: |\n")
+ steps = append(steps, fmt.Sprintf(" npm install -g @tobilu/qmd@%s\n", version))
+
+ // Build the index: register collections and index docs
+ steps = append(steps, " - name: Build qmd index\n")
+ steps = append(steps, " run: |\n")
+ steps = append(steps, " set -e\n")
+ steps = append(steps, " mkdir -p /tmp/gh-aw/qmd-index\n")
+
+ // Register collections based on configured globs.
+ // Each pattern is POSIX-single-quote escaped to prevent shell injection;
+ // single-quote wrapping means $, `, \, and ; are all treated as literals.
+ if len(qmdConfig.Docs) > 0 {
+ globArg := shellSingleQuote(strings.Join(qmdConfig.Docs, ","))
+ steps = append(steps, fmt.Sprintf(
+ " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"${GITHUB_WORKSPACE}\" --name docs --glob %s\n",
+ globArg,
+ ))
+ } else {
+ // Default: index all markdown files in the workspace
+ steps = append(steps, " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"${GITHUB_WORKSPACE}\" --name docs --glob '**/*.md'\n")
+ }
+
+ // Upload qmd index as a separate artifact for the agent job
+ qmdLog.Print("Adding qmd index artifact upload step")
+ qmdArtifactName := artifactPrefixExprForActivationJob(data) + constants.QmdArtifactName
+ steps = append(steps, " - name: Upload qmd index artifact\n")
+ steps = append(steps, " if: success()\n")
+ steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/upload-artifact")))
+ steps = append(steps, " with:\n")
+ steps = append(steps, fmt.Sprintf(" name: %s\n", qmdArtifactName))
+ steps = append(steps, " path: /tmp/gh-aw/qmd-index/\n")
+ steps = append(steps, " retention-days: 1\n")
+
+ return steps
+}
+
+// generateQmdDownloadStep generates the agent job step that downloads the qmd-index artifact.
+// Returns the steps as a YAML string slice ready to be appended to the agent job steps.
+func generateQmdDownloadStep(data *WorkflowData) string {
+ qmdArtifactName := artifactPrefixExprForDownstreamJob(data) + constants.QmdArtifactName
+ var sb strings.Builder
+ sb.WriteString(" - name: Download qmd index artifact\n")
+ fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/download-artifact"))
+ sb.WriteString(" with:\n")
+ fmt.Fprintf(&sb, " name: %s\n", qmdArtifactName)
+ sb.WriteString(" path: /tmp/gh-aw/qmd-index/\n")
+ return sb.String()
+}
diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go
index 81305ecaa8b..4ff3c75b048 100644
--- a/pkg/workflow/tools_parser.go
+++ b/pkg/workflow/tools_parser.go
@@ -101,6 +101,9 @@ func NewTools(toolsMap map[string]any) *Tools {
if val, exists := toolsMap["playwright"]; exists {
tools.Playwright = parsePlaywrightTool(val)
}
+ if val, exists := toolsMap["qmd"]; exists {
+ tools.Qmd = parseQmdTool(val)
+ }
if val, exists := toolsMap["serena"]; exists {
tools.Serena = parseSerenaTool(val)
}
@@ -128,6 +131,7 @@ func NewTools(toolsMap map[string]any) *Tools {
"web-search": true,
"edit": true,
"playwright": true,
+ "qmd": true,
"serena": true,
"agentic-workflows": true,
"cache-memory": true,
@@ -145,7 +149,7 @@ func NewTools(toolsMap map[string]any) *Tools {
}
}
- toolsParserLog.Printf("Parsed tools: github=%v, bash=%v, playwright=%v, serena=%v, custom=%d", tools.GitHub != nil, tools.Bash != nil, tools.Playwright != nil, tools.Serena != nil, customCount)
+ toolsParserLog.Printf("Parsed tools: github=%v, bash=%v, playwright=%v, qmd=%v, serena=%v, custom=%d", tools.GitHub != nil, tools.Bash != nil, tools.Playwright != nil, tools.Qmd != nil, tools.Serena != nil, customCount)
return tools
}
@@ -329,6 +333,37 @@ func parsePlaywrightTool(val any) *PlaywrightToolConfig {
return &PlaywrightToolConfig{}
}
+// parseQmdTool converts raw qmd tool configuration to QmdToolConfig.
+// The qmd tool requires a list of glob patterns (docs field) to specify which files to index.
+func parseQmdTool(val any) *QmdToolConfig {
+ if val == nil {
+ toolsParserLog.Print("qmd tool enabled with empty docs configuration")
+ return &QmdToolConfig{}
+ }
+
+ if configMap, ok := val.(map[string]any); ok {
+ config := &QmdToolConfig{}
+
+ // Handle docs field - list of glob patterns
+ if docsValue, ok := configMap["docs"]; ok {
+ if arr, ok := docsValue.([]any); ok {
+ config.Docs = make([]string, 0, len(arr))
+ for _, item := range arr {
+ if str, ok := item.(string); ok {
+ config.Docs = append(config.Docs, str)
+ }
+ }
+ } else if arr, ok := docsValue.([]string); ok {
+ config.Docs = arr
+ }
+ }
+
+ return config
+ }
+
+ return &QmdToolConfig{}
+}
+
// parseSerenaTool converts raw serena tool configuration to SerenaToolConfig
func parseSerenaTool(val any) *SerenaToolConfig {
if val == nil {
diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go
index c8943d4f826..b53a8a4bfa3 100644
--- a/pkg/workflow/tools_types.go
+++ b/pkg/workflow/tools_types.go
@@ -73,6 +73,7 @@ type ToolsConfig struct {
WebSearch *WebSearchToolConfig `yaml:"web-search,omitempty"`
Edit *EditToolConfig `yaml:"edit,omitempty"`
Playwright *PlaywrightToolConfig `yaml:"playwright,omitempty"`
+ Qmd *QmdToolConfig `yaml:"qmd,omitempty"`
Serena *SerenaToolConfig `yaml:"serena,omitempty"`
AgenticWorkflows *AgenticWorkflowsToolConfig `yaml:"agentic-workflows,omitempty"`
CacheMemory *CacheMemoryToolConfig `yaml:"cache-memory,omitempty"`
@@ -199,6 +200,9 @@ func (t *ToolsConfig) ToMap() map[string]any {
if t.Playwright != nil {
result["playwright"] = t.Playwright
}
+ if t.Qmd != nil {
+ result["qmd"] = t.Qmd
+ }
if t.Serena != nil {
// Convert back based on whether it was short syntax or object
if len(t.Serena.ShortSyntax) > 0 {
@@ -307,6 +311,16 @@ type PlaywrightToolConfig struct {
Args []string `yaml:"args,omitempty"`
}
+// QmdToolConfig represents the configuration for the qmd documentation search tool.
+// qmd (https://github.com/tobi/qmd) provides local vector search over documentation files.
+// The index is built in the activation job and downloaded by the agent job, so no
+// contents:read permission is needed in the agent job.
+type QmdToolConfig struct {
+ // Docs is the list of glob patterns for files to include in the search index.
+ // Example: ["docs/**/*.md", ".github/**/*.md"]
+ Docs []string `yaml:"docs"`
+}
+
// SerenaToolConfig represents the configuration for the Serena MCP tool
type SerenaToolConfig struct {
Version string `yaml:"version,omitempty"`
@@ -420,6 +434,8 @@ func (t *Tools) HasTool(name string) bool {
return t.Edit != nil
case "playwright":
return t.Playwright != nil
+ case "qmd":
+ return t.Qmd != nil
case "serena":
return t.Serena != nil
case "agentic-workflows":
@@ -465,6 +481,9 @@ func (t *Tools) GetToolNames() []string {
if t.Playwright != nil {
names = append(names, "playwright")
}
+ if t.Qmd != nil {
+ names = append(names, "qmd")
+ }
if t.Serena != nil {
names = append(names, "serena")
}
diff --git a/pkg/workflow/unified_prompt_step.go b/pkg/workflow/unified_prompt_step.go
index 96a4ff50d31..3d932376a0f 100644
--- a/pkg/workflow/unified_prompt_step.go
+++ b/pkg/workflow/unified_prompt_step.go
@@ -136,6 +136,15 @@ func (c *Compiler) collectPromptSections(data *WorkflowData) []PromptSection {
})
}
+ // 3a. qmd instructions (if qmd tool is enabled)
+ if hasQmdTool(data.ParsedTools) {
+ unifiedPromptLog.Print("Adding qmd section")
+ sections = append(sections, PromptSection{
+ Content: qmdPromptFile,
+ IsFile: true,
+ })
+ }
+
// 4. Agentic Workflows MCP guide (if agentic-workflows tool is enabled)
if hasAgenticWorkflowsTool(data.ParsedTools) {
unifiedPromptLog.Print("Adding agentic-workflows guide section")
From abf31d6214f3d20e7f5519e6b1f7b1c405016e51 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Mar 2026 20:41:23 +0000
Subject: [PATCH 03/49] Mark qmd tool as experimental with compiler warning
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/1dc55c60-1437-4497-a351-d1b7b8d88bfa
---
pkg/workflow/compiler.go | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go
index c0ce4a43a01..db76548960c 100644
--- a/pkg/workflow/compiler.go
+++ b/pkg/workflow/compiler.go
@@ -255,6 +255,12 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
c.IncrementWarningCount()
}
+ // Emit experimental warning for qmd documentation search feature
+ if workflowData.QmdConfig != nil {
+ fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Using experimental feature: qmd"))
+ c.IncrementWarningCount()
+ }
+
// Emit experimental warning for dependencies (APM) feature
if workflowData.APMDependencies != nil && len(workflowData.APMDependencies.Packages) > 0 {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Using experimental feature: dependencies (APM)"))
From e09c6970051211fa28e1bbe2ad53c18b37aa5bd6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Mar 2026 22:15:08 +0000
Subject: [PATCH 04/49] Add per-collection checkout support to qmd tool
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e9a784be-084b-40e0-9bf4-eb76dc81df6d
---
pkg/parser/schemas/main_workflow_schema.json | 96 +++++++++----
pkg/workflow/qmd.go | 135 +++++++++++++++++--
pkg/workflow/tools_parser.go | 60 ++++++++-
pkg/workflow/tools_types.go | 27 +++-
4 files changed, 282 insertions(+), 36 deletions(-)
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 691470f48a8..da64c5829d7 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -322,7 +322,7 @@
{
"type": "array",
"minItems": 1,
- "description": "Array of label names — any of these labels will trigger the workflow.",
+ "description": "Array of label names \u2014 any of these labels will trigger the workflow.",
"items": {
"type": "string",
"minLength": 1,
@@ -343,7 +343,7 @@
{
"type": "array",
"minItems": 1,
- "description": "Array of label names — any of these labels will trigger the workflow.",
+ "description": "Array of label names \u2014 any of these labels will trigger the workflow.",
"items": {
"type": "string",
"minLength": 1,
@@ -1506,12 +1506,12 @@
"description": "Skip workflow execution for specific GitHub users. Useful for preventing workflows from running for specific accounts (e.g., bots, specific team members)."
},
"roles": {
- "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (⚠️ security consideration).",
+ "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (\u26a0\ufe0f security consideration).",
"oneOf": [
{
"type": "string",
"enum": ["all"],
- "description": "Allow any authenticated user to trigger the workflow (⚠️ disables permission checking entirely - use with caution)"
+ "description": "Allow any authenticated user to trigger the workflow (\u26a0\ufe0f disables permission checking entirely - use with caution)"
},
{
"type": "array",
@@ -1864,7 +1864,7 @@
"vulnerability-alerts": {
"type": "string",
"enum": ["read", "write", "none"],
- "description": "Permission level for Dependabot vulnerability alerts (read/write/none). GitHub App-only permission: required to access Dependabot alerts via the GitHub MCP server. The GITHUB_TOKEN does not have this permission — a GitHub App must be configured."
+ "description": "Permission level for Dependabot vulnerability alerts (read/write/none). GitHub App-only permission: required to access Dependabot alerts via the GitHub MCP server. The GITHUB_TOKEN does not have this permission \u2014 a GitHub App must be configured."
},
"all": {
"type": "string",
@@ -2491,7 +2491,7 @@
},
"network": {
"$comment": "Strict mode requirements: When strict=true, the 'network' field must be present (not null/undefined) and cannot contain standalone wildcard '*' in allowed domains (but patterns like '*.example.com' ARE allowed). This is validated in Go code (pkg/workflow/strict_mode_validation.go) via validateStrictNetwork().",
- "description": "Network access control for AI engines using ecosystem identifiers and domain allowlists. Supports wildcard patterns like '*.example.com' to match any subdomain. Controls web fetch and search capabilities. IMPORTANT: For workflows that build/install/test code, always include the language ecosystem identifier alongside 'defaults' — 'defaults' alone only covers basic infrastructure, not package registries. Key ecosystem identifiers by runtime: 'dotnet' (.NET/NuGet), 'python' (pip/PyPI), 'node' (npm/yarn), 'go' (go modules), 'java' (Maven/Gradle), 'ruby' (Bundler), 'rust' (Cargo), 'swift' (Swift PM). Example: a .NET project needs network: { allowed: [defaults, dotnet] }.",
+ "description": "Network access control for AI engines using ecosystem identifiers and domain allowlists. Supports wildcard patterns like '*.example.com' to match any subdomain. Controls web fetch and search capabilities. IMPORTANT: For workflows that build/install/test code, always include the language ecosystem identifier alongside 'defaults' \u2014 'defaults' alone only covers basic infrastructure, not package registries. Key ecosystem identifiers by runtime: 'dotnet' (.NET/NuGet), 'python' (pip/PyPI), 'node' (npm/yarn), 'go' (go modules), 'java' (Maven/Gradle), 'ruby' (Bundler), 'rust' (Cargo), 'swift' (Swift PM). Example: a .NET project needs network: { allowed: [defaults, dotnet] }.",
"examples": [
"defaults",
{
@@ -2960,7 +2960,7 @@
[
{
"name": "Verify Post-Steps Execution",
- "run": "echo \"✅ Post-steps are executing correctly\"\necho \"This step runs after the AI agent completes\"\n"
+ "run": "echo \"\u2705 Post-steps are executing correctly\"\necho \"This step runs after the AI agent completes\"\n"
},
{
"name": "Upload Test Results",
@@ -3365,18 +3365,41 @@
"properties": {
"docs": {
"type": "array",
- "description": "List of glob patterns for documentation files to include in the search index.",
+ "description": "List of glob patterns for documentation files to include in the search index. Shorthand for a single collection targeting the current repository. Mutually exclusive with collections.",
"items": {
"type": "string"
},
"examples": [["docs/**/*.md", ".github/**/*.md"]]
+ },
+ "collections": {
+ "type": "array",
+ "description": "List of named documentation collections, each optionally targeting a different repository via its own checkout configuration. Mutually exclusive with docs.",
+ "items": {
+ "$ref": "#/$defs/qmdCollection"
+ },
+ "minItems": 1
}
},
- "required": ["docs"],
"additionalProperties": false,
"examples": [
{
"docs": ["docs/**/*.md", ".github/**/*.md"]
+ },
+ {
+ "collections": [
+ {
+ "name": "current-docs",
+ "docs": ["docs/**/*.md"]
+ },
+ {
+ "name": "other-docs",
+ "docs": ["docs/**/*.md"],
+ "checkout": {
+ "repository": "owner/other-repo",
+ "path": "./other-repo"
+ }
+ }
+ ]
}
]
},
@@ -5540,7 +5563,7 @@
"items": {
"type": "string"
},
- "description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern — files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)."
+ "description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern \u2014 files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)."
},
"preserve-branch-name": {
"type": "boolean",
@@ -5775,7 +5798,7 @@
"oneOf": [
{
"type": "object",
- "description": "Configuration for resolving review threads on pull requests. Resolution is scoped to the triggering PR only — threads on other PRs cannot be resolved.",
+ "description": "Configuration for resolving review threads on pull requests. Resolution is scoped to the triggering PR only \u2014 threads on other PRs cannot be resolved.",
"properties": {
"max": {
"description": "Maximum number of review threads to resolve (default: 10) Supports integer or GitHub Actions expression (e.g. '${{ inputs.max }}').",
@@ -6689,7 +6712,7 @@
"items": {
"type": "string"
},
- "description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern — files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)."
+ "description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern \u2014 files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)."
},
"excluded-files": {
"type": "array",
@@ -7458,7 +7481,7 @@
},
"scripts": {
"type": "object",
- "description": "Inline JavaScript script handlers that run inside the consolidated safe-outputs job handler loop. Unlike 'jobs' (which create separate GitHub Actions jobs), scripts execute in-process alongside the built-in handlers. Users write only the body of the main function — the compiler wraps it with 'async function main(config = {}) { ... }' and 'module.exports = { main };' automatically. Script names containing dashes will be automatically normalized to underscores (e.g., 'post-slack-message' becomes 'post_slack_message').",
+ "description": "Inline JavaScript script handlers that run inside the consolidated safe-outputs job handler loop. Unlike 'jobs' (which create separate GitHub Actions jobs), scripts execute in-process alongside the built-in handlers. Users write only the body of the main function \u2014 the compiler wraps it with 'async function main(config = {}) { ... }' and 'module.exports = { main };' automatically. Script names containing dashes will be automatically normalized to underscores (e.g., 'post-slack-message' becomes 'post_slack_message').",
"patternProperties": {
"^[a-zA-Z_][a-zA-Z0-9_-]*$": {
"type": "object",
@@ -7516,7 +7539,7 @@
},
"script": {
"type": "string",
- "description": "JavaScript handler body. Write only the code that runs inside the handler for each item — the compiler generates the full outer wrapper including config input destructuring (`const { channel, message } = config;`) and the handler function (`return async function handleX(item, resolvedTemporaryIds) { ... }`). The body has access to `item` (runtime message with input values), `resolvedTemporaryIds` (map of temporary IDs), and config-destructured local variables for each declared input."
+ "description": "JavaScript handler body. Write only the code that runs inside the handler for each item \u2014 the compiler generates the full outer wrapper including config input destructuring (`const { channel, message } = config;`) and the handler function (`return async function handleX(item, resolvedTemporaryIds) { ... }`). The body has access to `item` (runtime message with input values), `resolvedTemporaryIds` (map of temporary IDs), and config-destructured local variables for each declared input."
}
},
"required": ["script"],
@@ -7551,8 +7574,8 @@
},
"staged-title": {
"type": "string",
- "description": "Custom title template for staged mode preview. Available placeholders: {operation}. Example: '🎭 Preview: {operation}'",
- "examples": ["🎭 Preview: {operation}", "## Staged Mode: {operation}"]
+ "description": "Custom title template for staged mode preview. Available placeholders: {operation}. Example: '\ud83c\udfad Preview: {operation}'",
+ "examples": ["\ud83c\udfad Preview: {operation}", "## Staged Mode: {operation}"]
},
"staged-description": {
"type": "string",
@@ -7566,18 +7589,18 @@
},
"run-success": {
"type": "string",
- "description": "Custom message template for successful workflow completion. Available placeholders: {workflow_name}, {run_url}. Default: '✅ Agentic [{workflow_name}]({run_url}) completed successfully.'",
- "examples": ["✅ Agentic [{workflow_name}]({run_url}) completed successfully.", "✅ [{workflow_name}]({run_url}) finished."]
+ "description": "Custom message template for successful workflow completion. Available placeholders: {workflow_name}, {run_url}. Default: '\u2705 Agentic [{workflow_name}]({run_url}) completed successfully.'",
+ "examples": ["\u2705 Agentic [{workflow_name}]({run_url}) completed successfully.", "\u2705 [{workflow_name}]({run_url}) finished."]
},
"run-failure": {
"type": "string",
- "description": "Custom message template for failed workflow. Available placeholders: {workflow_name}, {run_url}, {status}. Default: '❌ Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.'",
- "examples": ["❌ Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.", "❌ [{workflow_name}]({run_url}) {status}."]
+ "description": "Custom message template for failed workflow. Available placeholders: {workflow_name}, {run_url}, {status}. Default: '\u274c Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.'",
+ "examples": ["\u274c Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.", "\u274c [{workflow_name}]({run_url}) {status}."]
},
"detection-failure": {
"type": "string",
- "description": "Custom message template for detection job failure. Available placeholders: {workflow_name}, {run_url}. Default: '⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.'",
- "examples": ["⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.", "⚠️ Detection job failed in [{workflow_name}]({run_url})."]
+ "description": "Custom message template for detection job failure. Available placeholders: {workflow_name}, {run_url}. Default: '\u26a0\ufe0f Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.'",
+ "examples": ["\u26a0\ufe0f Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.", "\u26a0\ufe0f Detection job failed in [{workflow_name}]({run_url})."]
},
"agent-failure-issue": {
"type": "string",
@@ -8223,7 +8246,7 @@
},
"github-token": {
"type": "string",
- "description": "GitHub token expression to authenticate APM with private package repositories. Uses cascading fallback (GH_AW_PLUGINS_TOKEN → GH_AW_GITHUB_TOKEN → GITHUB_TOKEN) when not specified. Takes effect unless github-app is also configured (which takes precedence).",
+ "description": "GitHub token expression to authenticate APM with private package repositories. Uses cascading fallback (GH_AW_PLUGINS_TOKEN \u2192 GH_AW_GITHUB_TOKEN \u2192 GITHUB_TOKEN) when not specified. Takes effect unless github-app is also configured (which takes precedence).",
"examples": ["${{ secrets.MY_TOKEN }}", "${{ secrets.GH_AW_GITHUB_TOKEN }}"]
}
},
@@ -8799,7 +8822,7 @@
},
"auth": {
"type": "array",
- "description": "Authentication bindings — maps logical roles (e.g. 'api-key') to GitHub Actions secret names",
+ "description": "Authentication bindings \u2014 maps logical roles (e.g. 'api-key') to GitHub Actions secret names",
"items": {
"type": "object",
"properties": {
@@ -9209,6 +9232,31 @@
"examples": [["*"], ["refs/pulls/open/*"], ["main", "feature/my-branch"], ["feature/*"]]
}
}
+ },
+ "qmdCollection": {
+ "type": "object",
+ "description": "A named documentation collection for the qmd tool. Each collection can optionally target a different repository via its own checkout configuration.",
+ "required": ["docs"],
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Collection identifier used in the qmd index. Defaults to 'docs' for single-collection configs or 'docs-' for multiple collections."
+ },
+ "docs": {
+ "type": "array",
+ "description": "List of glob patterns for documentation files to include in this collection.",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1,
+ "examples": [["docs/**/*.md", ".github/**/*.md"]]
+ },
+ "checkout": {
+ "$ref": "#/$defs/checkoutConfig",
+ "description": "Optional checkout configuration for this collection. When set, the specified repository is checked out and its files are indexed. Defaults to the current repository if not set."
+ }
+ }
}
}
}
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 2b7b5aebdc6..95d932d5e43 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -66,11 +66,111 @@ func hasQmdTool(parsedTools *Tools) bool {
return parsedTools.Qmd != nil
}
+// resolvedQmdCollection is an internal representation of a qmd collection
+// with its working directory resolved.
+type resolvedQmdCollection struct {
+ name string
+ docs []string
+ workdir string // absolute path within the runner (e.g. ${GITHUB_WORKSPACE} or /tmp/gh-aw/qmd-checkout-)
+}
+
+// resolveQmdCollections converts a QmdToolConfig into a list of resolvedQmdCollections.
+// Collections that require a custom checkout will have their workdir set to a temporary
+// path under /tmp/gh-aw/.
+func resolveQmdCollections(qmdConfig *QmdToolConfig) []resolvedQmdCollection {
+ // Extended form: explicit collections list
+ if len(qmdConfig.Collections) > 0 {
+ resolved := make([]resolvedQmdCollection, 0, len(qmdConfig.Collections))
+ for _, col := range qmdConfig.Collections {
+ name := col.Name
+ if name == "" {
+ name = "docs"
+ }
+ workdir := "${GITHUB_WORKSPACE}"
+ if col.Checkout != nil {
+ if col.Checkout.Path != "" {
+ // Checkout path is relative to GITHUB_WORKSPACE; strip leading "./" for cleanliness
+ checkoutPath := strings.TrimPrefix(col.Checkout.Path, "./")
+ workdir = "${GITHUB_WORKSPACE}/" + checkoutPath
+ } else {
+ // No explicit path → use an isolated temp directory
+ workdir = "/tmp/gh-aw/qmd-checkout-" + name
+ }
+ }
+ resolved = append(resolved, resolvedQmdCollection{
+ name: name,
+ docs: col.Docs,
+ workdir: workdir,
+ })
+ }
+ return resolved
+ }
+
+ // Simple form: docs shorthand → single default collection
+ docs := qmdConfig.Docs
+ if len(docs) == 0 {
+ docs = []string{"**/*.md"}
+ }
+ return []resolvedQmdCollection{{
+ name: "docs",
+ docs: docs,
+ workdir: "${GITHUB_WORKSPACE}",
+ }}
+}
+
+// generateQmdCollectionCheckoutStep generates a checkout step YAML string for a qmd
+// collection that targets a non-default repository. Returns an empty string when the
+// collection uses the current repository (no checkout needed).
+func generateQmdCollectionCheckoutStep(col *QmdDocCollection) string {
+ if col.Checkout == nil {
+ return ""
+ }
+ cfg := col.Checkout
+
+ // Determine checkout path used in the runner filesystem
+ checkoutPath := cfg.Path
+ if checkoutPath == "" {
+ checkoutPath = "/tmp/gh-aw/qmd-checkout-" + col.Name
+ }
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, " - name: Checkout %s for qmd\n", col.Name)
+ fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/checkout"))
+ sb.WriteString(" with:\n")
+ sb.WriteString(" persist-credentials: false\n")
+ if cfg.Repository != "" {
+ fmt.Fprintf(&sb, " repository: %s\n", cfg.Repository)
+ }
+ if cfg.Ref != "" {
+ fmt.Fprintf(&sb, " ref: %s\n", cfg.Ref)
+ }
+ fmt.Fprintf(&sb, " path: %s\n", checkoutPath)
+ if cfg.GitHubToken != "" {
+ fmt.Fprintf(&sb, " token: %s\n", cfg.GitHubToken)
+ }
+ if cfg.FetchDepth != nil {
+ fmt.Fprintf(&sb, " fetch-depth: %d\n", *cfg.FetchDepth)
+ }
+ if cfg.SparseCheckout != "" {
+ sb.WriteString(" sparse-checkout: |\n")
+ for line := range strings.SplitSeq(strings.TrimRight(cfg.SparseCheckout, "\n"), "\n") {
+ fmt.Fprintf(&sb, " %s\n", strings.TrimSpace(line))
+ }
+ }
+ if cfg.Submodules != "" {
+ fmt.Fprintf(&sb, " submodules: %s\n", cfg.Submodules)
+ }
+ if cfg.LFS {
+ sb.WriteString(" lfs: true\n")
+ }
+ return sb.String()
+}
+
// generateQmdIndexSteps generates the activation job steps that install qmd, register
// collections for each configured doc glob, and build the vector search index.
// The index is stored at /tmp/gh-aw/qmd-index and uploaded as the qmd-index artifact.
func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []string {
- qmdLog.Printf("Generating qmd index steps: docs=%v", qmdConfig.Docs)
+ qmdLog.Printf("Generating qmd index steps: docs=%v collections=%d", qmdConfig.Docs, len(qmdConfig.Collections))
version := string(constants.DefaultQmdVersion)
var steps []string
@@ -86,24 +186,41 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
steps = append(steps, " run: |\n")
steps = append(steps, fmt.Sprintf(" npm install -g @tobilu/qmd@%s\n", version))
+ // Emit a checkout step for each collection that needs its own repo
+ if len(qmdConfig.Collections) > 0 {
+ for _, col := range qmdConfig.Collections {
+ if checkoutStep := generateQmdCollectionCheckoutStep(col); checkoutStep != "" {
+ steps = append(steps, checkoutStep)
+ }
+ }
+ }
+
// Build the index: register collections and index docs
steps = append(steps, " - name: Build qmd index\n")
steps = append(steps, " run: |\n")
steps = append(steps, " set -e\n")
steps = append(steps, " mkdir -p /tmp/gh-aw/qmd-index\n")
- // Register collections based on configured globs.
- // Each pattern is POSIX-single-quote escaped to prevent shell injection;
+ // Register each resolved collection.
+ // Each glob pattern is POSIX-single-quote escaped to prevent shell injection;
// single-quote wrapping means $, `, \, and ; are all treated as literals.
- if len(qmdConfig.Docs) > 0 {
- globArg := shellSingleQuote(strings.Join(qmdConfig.Docs, ","))
+ // The workdir is double-quoted to preserve ${GITHUB_WORKSPACE} variable expansion
+ // while still guarding against word-splitting on paths that contain spaces.
+ // The name and glob args come from user input so they are single-quoted.
+ collections := resolveQmdCollections(qmdConfig)
+ for _, col := range collections {
+ var globArg string
+ if len(col.docs) > 0 {
+ globArg = shellSingleQuote(strings.Join(col.docs, ","))
+ } else {
+ globArg = "'**/*.md'"
+ }
steps = append(steps, fmt.Sprintf(
- " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"${GITHUB_WORKSPACE}\" --name docs --glob %s\n",
+ " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"%s\" --name %s --glob %s\n",
+ col.workdir,
+ shellSingleQuote(col.name),
globArg,
))
- } else {
- // Default: index all markdown files in the workspace
- steps = append(steps, " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"${GITHUB_WORKSPACE}\" --name docs --glob '**/*.md'\n")
}
// Upload qmd index as a separate artifact for the agent job
diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go
index 4ff3c75b048..15e4156f77b 100644
--- a/pkg/workflow/tools_parser.go
+++ b/pkg/workflow/tools_parser.go
@@ -334,7 +334,9 @@ func parsePlaywrightTool(val any) *PlaywrightToolConfig {
}
// parseQmdTool converts raw qmd tool configuration to QmdToolConfig.
-// The qmd tool requires a list of glob patterns (docs field) to specify which files to index.
+// The qmd tool supports two forms:
+// - Simple: docs field with a list of glob patterns (single collection, current repo)
+// - Extended: collections field with named collections, each optionally with a checkout
func parseQmdTool(val any) *QmdToolConfig {
if val == nil {
toolsParserLog.Print("qmd tool enabled with empty docs configuration")
@@ -344,7 +346,24 @@ func parseQmdTool(val any) *QmdToolConfig {
if configMap, ok := val.(map[string]any); ok {
config := &QmdToolConfig{}
- // Handle docs field - list of glob patterns
+ // Handle collections field - list of named collections with optional checkout
+ if collectionsValue, ok := configMap["collections"]; ok {
+ if arr, ok := collectionsValue.([]any); ok {
+ config.Collections = make([]*QmdDocCollection, 0, len(arr))
+ for i, item := range arr {
+ itemMap, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ col := parseQmdDocCollection(itemMap, i)
+ config.Collections = append(config.Collections, col)
+ }
+ toolsParserLog.Printf("qmd tool parsed %d collections", len(config.Collections))
+ return config
+ }
+ }
+
+ // Handle docs field - simple glob list (backward-compatible single collection)
if docsValue, ok := configMap["docs"]; ok {
if arr, ok := docsValue.([]any); ok {
config.Docs = make([]string, 0, len(arr))
@@ -364,6 +383,43 @@ func parseQmdTool(val any) *QmdToolConfig {
return &QmdToolConfig{}
}
+// parseQmdDocCollection converts a raw map to a QmdDocCollection.
+// The index parameter is used to generate a default name when none is provided.
+func parseQmdDocCollection(m map[string]any, index int) *QmdDocCollection {
+ col := &QmdDocCollection{}
+
+ if name, ok := m["name"].(string); ok && name != "" {
+ col.Name = name
+ } else {
+ col.Name = fmt.Sprintf("docs-%d", index)
+ }
+
+ if docsValue, ok := m["docs"]; ok {
+ if arr, ok := docsValue.([]any); ok {
+ col.Docs = make([]string, 0, len(arr))
+ for _, item := range arr {
+ if str, ok := item.(string); ok {
+ col.Docs = append(col.Docs, str)
+ }
+ }
+ } else if arr, ok := docsValue.([]string); ok {
+ col.Docs = arr
+ }
+ }
+
+ if checkoutValue, ok := m["checkout"]; ok {
+ if checkoutMap, ok := checkoutValue.(map[string]any); ok {
+ if cfg, err := checkoutConfigFromMap(checkoutMap); err == nil {
+ col.Checkout = cfg
+ } else {
+ toolsParserLog.Printf("qmd collection %q: ignoring invalid checkout config: %v", col.Name, err)
+ }
+ }
+ }
+
+ return col
+}
+
// parseSerenaTool converts raw serena tool configuration to SerenaToolConfig
func parseSerenaTool(val any) *SerenaToolConfig {
if val == nil {
diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go
index b53a8a4bfa3..6fca0911d9e 100644
--- a/pkg/workflow/tools_types.go
+++ b/pkg/workflow/tools_types.go
@@ -311,14 +311,39 @@ type PlaywrightToolConfig struct {
Args []string `yaml:"args,omitempty"`
}
+// QmdDocCollection represents a named documentation collection for the qmd tool.
+// Each collection indexes its own set of files and can optionally target a different
+// repository via its own checkout configuration.
+type QmdDocCollection struct {
+ // Name is the collection identifier used in the qmd index.
+ // Defaults to "docs" for single-collection configs or "docs-" for multiple collections.
+ Name string `yaml:"name,omitempty"`
+
+ // Docs is the list of glob patterns for files to include in this collection.
+ // Example: ["docs/**/*.md", ".github/**/*.md"]
+ Docs []string `yaml:"docs"`
+
+ // Checkout configures which repository to check out for this collection.
+ // Uses the same syntax as the top-level checkout configuration.
+ // Defaults to the current repository if not set.
+ Checkout *CheckoutConfig `yaml:"checkout,omitempty"`
+}
+
// QmdToolConfig represents the configuration for the qmd documentation search tool.
// qmd (https://github.com/tobi/qmd) provides local vector search over documentation files.
// The index is built in the activation job and downloaded by the agent job, so no
// contents:read permission is needed in the agent job.
type QmdToolConfig struct {
// Docs is the list of glob patterns for files to include in the search index.
+ // Shorthand for a single default collection targeting the current repository.
+ // Mutually exclusive with Collections.
// Example: ["docs/**/*.md", ".github/**/*.md"]
- Docs []string `yaml:"docs"`
+ Docs []string `yaml:"docs,omitempty"`
+
+ // Collections is the list of named documentation collections.
+ // Each collection can specify its own checkout to target a different repository.
+ // When both Docs and Collections are set, Collections takes precedence and Docs is ignored.
+ Collections []*QmdDocCollection `yaml:"collections,omitempty"`
}
// SerenaToolConfig represents the configuration for the Serena MCP tool
From 8e48ff629568c328c9ea9bed32255e5e2b527be2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Mar 2026 22:32:57 +0000
Subject: [PATCH 05/49] Add searches sub-key and rename collections to
checkouts in qmd config
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e854d5ea-603f-40f9-8672-434f0246af32
---
pkg/parser/schemas/main_workflow_schema.json | 84 +++++++++-
pkg/workflow/qmd.go | 153 ++++++++++++++-----
pkg/workflow/tools_parser.go | 104 +++++++++++--
pkg/workflow/tools_types.go | 47 +++++-
4 files changed, 329 insertions(+), 59 deletions(-)
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index da64c5829d7..39aa75ebc40 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -3360,12 +3360,28 @@
"examples": [true, null]
},
"qmd": {
- "description": "qmd documentation search tool (https://github.com/tobi/qmd). Builds a local vector search index of documentation files during the activation job and mounts a search MCP server in the agent job. The agent job does not need contents:read permission since the index is pre-built.",
+ "description": "qmd documentation search tool (https://github.com/tobi/qmd). Builds a local vector search index during the activation job and mounts a search MCP server in the agent job. The agent job does not need contents:read permission since the index is pre-built.",
"type": "object",
"properties": {
+ "checkouts": {
+ "type": "array",
+ "description": "List of named documentation collections built from checked-out repositories. Each entry can optionally specify its own checkout configuration to target a different repository.",
+ "items": {
+ "$ref": "#/$defs/qmdCollection"
+ },
+ "minItems": 1
+ },
+ "searches": {
+ "type": "array",
+ "description": "List of GitHub search queries whose results are downloaded and added to the qmd index.",
+ "items": {
+ "$ref": "#/$defs/qmdSearchEntry"
+ },
+ "minItems": 1
+ },
"docs": {
"type": "array",
- "description": "List of glob patterns for documentation files to include in the search index. Shorthand for a single collection targeting the current repository. Mutually exclusive with collections.",
+ "description": "Legacy shorthand: list of glob patterns for a single collection targeting the current repository. Use checkouts for more control.",
"items": {
"type": "string"
},
@@ -3373,7 +3389,7 @@
},
"collections": {
"type": "array",
- "description": "List of named documentation collections, each optionally targeting a different repository via its own checkout configuration. Mutually exclusive with docs.",
+ "description": "Legacy: use checkouts instead. List of named documentation collections (backward compatible).",
"items": {
"$ref": "#/$defs/qmdCollection"
},
@@ -3386,7 +3402,7 @@
"docs": ["docs/**/*.md", ".github/**/*.md"]
},
{
- "collections": [
+ "checkouts": [
{
"name": "current-docs",
"docs": ["docs/**/*.md"]
@@ -3400,6 +3416,31 @@
}
}
]
+ },
+ {
+ "searches": [
+ {
+ "query": "repo:owner/repo language:Markdown path:docs/",
+ "min": 1,
+ "max": 30,
+ "github-token": "${{ secrets.GITHUB_TOKEN }}"
+ }
+ ]
+ },
+ {
+ "checkouts": [
+ {
+ "name": "local-docs",
+ "docs": ["docs/**/*.md"]
+ }
+ ],
+ "searches": [
+ {
+ "query": "org:myorg language:Markdown",
+ "max": 50,
+ "github-token": "${{ secrets.GITHUB_TOKEN }}"
+ }
+ ]
}
]
},
@@ -9235,7 +9276,7 @@
},
"qmdCollection": {
"type": "object",
- "description": "A named documentation collection for the qmd tool. Each collection can optionally target a different repository via its own checkout configuration.",
+ "description": "A named documentation collection for the qmd tool, built from a checked-out repository. Each collection can optionally target a different repository via its own checkout configuration.",
"required": ["docs"],
"additionalProperties": false,
"properties": {
@@ -9257,6 +9298,39 @@
"description": "Optional checkout configuration for this collection. When set, the specified repository is checked out and its files are indexed. Defaults to the current repository if not set."
}
}
+ },
+ "qmdSearchEntry": {
+ "type": "object",
+ "description": "A GitHub search query entry for the qmd tool. The search is executed during the activation job and the matching files are downloaded and added to the qmd index.",
+ "required": ["query"],
+ "additionalProperties": false,
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "GitHub code search query string. See https://docs.github.com/en/search-github/searching-on-github/searching-code for syntax.",
+ "examples": ["repo:owner/repo language:Markdown path:docs/", "org:myorg language:Markdown"]
+ },
+ "min": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Minimum number of results required. If fewer results are returned the activation job fails."
+ },
+ "max": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 30,
+ "description": "Maximum number of search results to download. Defaults to 30."
+ },
+ "github-token": {
+ "type": "string",
+ "description": "GitHub token used to authenticate the search API request. Mutually exclusive with github-app.",
+ "examples": ["${{ secrets.GITHUB_TOKEN }}", "${{ secrets.MY_PAT }}"]
+ },
+ "github-app": {
+ "$ref": "#/$defs/github_app",
+ "description": "GitHub App configuration used to mint a token for the search API request. Mutually exclusive with github-token."
+ }
+ }
}
}
}
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 95d932d5e43..abf5791e15b 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -7,9 +7,9 @@
//
// The integration has two phases:
//
-// 1. Activation job: builds the search index from configured doc globs and uploads it as
-// the "qmd-index" artifact. This step runs in the activation job which already has
-// contents:read permission, so the agent job does NOT need contents:read to search docs.
+// 1. Activation job: builds the search index from configured checkouts and/or GitHub searches
+// and uploads it as the "qmd-index" artifact. This step runs in the activation job which
+// already has contents:read permission, so the agent job does NOT need contents:read.
//
// 2. Agent job: downloads the "qmd-index" artifact and mounts the qmd MCP server pointing
// at the pre-built index. The MCP server exposes a search tool that the agent can use
@@ -17,13 +17,25 @@
//
// # Configuration
//
+// Two sources can populate the index:
+//
+// - checkouts: glob-based collections from checked-out repositories (each optionally with
+// its own checkout config to target a different repo)
+// - searches: GitHub search queries whose results are downloaded and added to the index
+//
// Example frontmatter:
//
// tools:
// qmd:
-// docs:
-// - docs/**/*.md
-// - .github/**/*.md
+// checkouts:
+// - name: docs
+// docs:
+// - docs/**/*.md
+// searches:
+// - query: "repo:owner/repo language:Markdown path:docs/"
+// min: 1
+// max: 30
+// github-token: ${{ secrets.GITHUB_TOKEN }}
//
// # Artifact lifecycle
//
@@ -31,8 +43,8 @@
// via the "qmd-index" artifact. Retention is 1 day (same as the activation artifact).
//
// Related files:
-// - tools_types.go: QmdToolConfig type
-// - tools_parser.go: parseQmdTool function
+// - tools_types.go: QmdToolConfig, QmdDocCollection, QmdSearchEntry types
+// - tools_parser.go: parseQmdTool / parseQmdDocCollection / parseQmdSearchEntry
// - mcp_renderer_builtin.go: RenderQmdMCP method
// - compiler_activation_job.go: activation job qmd index steps
// - compiler_yaml_main_job.go: agent job qmd artifact download
@@ -74,14 +86,13 @@ type resolvedQmdCollection struct {
workdir string // absolute path within the runner (e.g. ${GITHUB_WORKSPACE} or /tmp/gh-aw/qmd-checkout-)
}
-// resolveQmdCollections converts a QmdToolConfig into a list of resolvedQmdCollections.
-// Collections that require a custom checkout will have their workdir set to a temporary
-// path under /tmp/gh-aw/.
-func resolveQmdCollections(qmdConfig *QmdToolConfig) []resolvedQmdCollection {
- // Extended form: explicit collections list
- if len(qmdConfig.Collections) > 0 {
- resolved := make([]resolvedQmdCollection, 0, len(qmdConfig.Collections))
- for _, col := range qmdConfig.Collections {
+// resolveQmdCheckouts converts the checkouts (or legacy docs) portion of a QmdToolConfig
+// into a list of resolvedQmdCollections.
+func resolveQmdCheckouts(qmdConfig *QmdToolConfig) []resolvedQmdCollection {
+ // Structured form: explicit checkouts list
+ if len(qmdConfig.Checkouts) > 0 {
+ resolved := make([]resolvedQmdCollection, 0, len(qmdConfig.Checkouts))
+ for _, col := range qmdConfig.Checkouts {
name := col.Name
if name == "" {
name = "docs"
@@ -106,16 +117,20 @@ func resolveQmdCollections(qmdConfig *QmdToolConfig) []resolvedQmdCollection {
return resolved
}
- // Simple form: docs shorthand → single default collection
+ // Legacy form: docs shorthand → single default collection
docs := qmdConfig.Docs
- if len(docs) == 0 {
+ if len(docs) == 0 && len(qmdConfig.Searches) == 0 {
+ // No explicit docs and no searches → default to all markdown
docs = []string{"**/*.md"}
}
- return []resolvedQmdCollection{{
- name: "docs",
- docs: docs,
- workdir: "${GITHUB_WORKSPACE}",
- }}
+ if len(docs) > 0 {
+ return []resolvedQmdCollection{{
+ name: "docs",
+ docs: docs,
+ workdir: "${GITHUB_WORKSPACE}",
+ }}
+ }
+ return nil
}
// generateQmdCollectionCheckoutStep generates a checkout step YAML string for a qmd
@@ -166,11 +181,69 @@ func generateQmdCollectionCheckoutStep(col *QmdDocCollection) string {
return sb.String()
}
+// generateQmdSearchStep generates an activation-job step that runs a GitHub search query,
+// downloads the matching files, and adds them as a qmd collection named after the search index.
+// The step uses the gh CLI to execute the search.
+func generateQmdSearchStep(entry *QmdSearchEntry, index int) string {
+ collectionName := fmt.Sprintf("search-%d", index)
+ searchDir := fmt.Sprintf("/tmp/gh-aw/qmd-search-%d", index)
+
+ maxResults := entry.Max
+ if maxResults <= 0 {
+ maxResults = 30
+ }
+
+ // Build the GH_TOKEN env override if a custom token is provided
+ var tokenEnv string
+ if entry.GitHubToken != "" {
+ tokenEnv = fmt.Sprintf("GH_TOKEN=%s ", entry.GitHubToken)
+ }
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, " - name: Search GitHub for qmd collection %q\n", collectionName)
+ sb.WriteString(" run: |\n")
+ sb.WriteString(" set -e\n")
+ fmt.Fprintf(&sb, " mkdir -p %s\n", searchDir)
+
+ // Execute gh search code, download each result file, then register the collection
+ sb.WriteString(" # Download search results and add them to the qmd index\n")
+ fmt.Fprintf(&sb, " %sgh search code %s --limit %d --json path,repository | \\\n",
+ tokenEnv,
+ shellSingleQuote(entry.Query),
+ maxResults,
+ )
+ // Use jq to extract repo+path pairs and download each file via gh api
+ fmt.Fprintf(&sb, " jq -r '.[] | .repository.fullName + \" \" + .path' | \\\n")
+ fmt.Fprintf(&sb, " while IFS=' ' read -r repo file_path; do\n")
+ fmt.Fprintf(&sb, " dest=%s/\"${repo//\\//-}\"-\"${file_path//\\//-}\"\n", searchDir)
+ fmt.Fprintf(&sb, " %sgh api \"repos/$repo/contents/$file_path\" --jq '.content' | base64 -d > \"$dest\" 2>/dev/null || true\n", tokenEnv)
+ fmt.Fprintf(&sb, " done\n")
+
+ // Enforce minimum count
+ if entry.Min > 0 {
+ fmt.Fprintf(&sb, " count=$(find %s -type f | wc -l)\n", searchDir)
+ fmt.Fprintf(&sb, " if [ \"$count\" -lt %d ]; then\n", entry.Min)
+ fmt.Fprintf(&sb, " echo \"qmd search %q returned $count results, minimum is %d\" >&2\n", collectionName, entry.Min)
+ sb.WriteString(" exit 1\n")
+ sb.WriteString(" fi\n")
+ }
+
+ // Add the downloaded files as a qmd collection
+ fmt.Fprintf(&sb, " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add %s --name %s --glob %s\n",
+ shellSingleQuote(searchDir),
+ shellSingleQuote(collectionName),
+ "'**/*'",
+ )
+
+ return sb.String()
+}
+
// generateQmdIndexSteps generates the activation job steps that install qmd, register
-// collections for each configured doc glob, and build the vector search index.
+// collections for each configured checkout and/or search, and build the vector search index.
// The index is stored at /tmp/gh-aw/qmd-index and uploaded as the qmd-index artifact.
func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []string {
- qmdLog.Printf("Generating qmd index steps: docs=%v collections=%d", qmdConfig.Docs, len(qmdConfig.Collections))
+ qmdLog.Printf("Generating qmd index steps: docs=%v checkouts=%d searches=%d",
+ qmdConfig.Docs, len(qmdConfig.Checkouts), len(qmdConfig.Searches))
version := string(constants.DefaultQmdVersion)
var steps []string
@@ -186,29 +259,24 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
steps = append(steps, " run: |\n")
steps = append(steps, fmt.Sprintf(" npm install -g @tobilu/qmd@%s\n", version))
- // Emit a checkout step for each collection that needs its own repo
- if len(qmdConfig.Collections) > 0 {
- for _, col := range qmdConfig.Collections {
- if checkoutStep := generateQmdCollectionCheckoutStep(col); checkoutStep != "" {
- steps = append(steps, checkoutStep)
- }
+ // Emit a checkout step for each checkout-based collection that needs its own repo
+ for _, col := range qmdConfig.Checkouts {
+ if checkoutStep := generateQmdCollectionCheckoutStep(col); checkoutStep != "" {
+ steps = append(steps, checkoutStep)
}
}
- // Build the index: register collections and index docs
+ // Build the index: create the cache dir and register all collections
steps = append(steps, " - name: Build qmd index\n")
steps = append(steps, " run: |\n")
steps = append(steps, " set -e\n")
steps = append(steps, " mkdir -p /tmp/gh-aw/qmd-index\n")
- // Register each resolved collection.
- // Each glob pattern is POSIX-single-quote escaped to prevent shell injection;
- // single-quote wrapping means $, `, \, and ; are all treated as literals.
- // The workdir is double-quoted to preserve ${GITHUB_WORKSPACE} variable expansion
- // while still guarding against word-splitting on paths that contain spaces.
- // The name and glob args come from user input so they are single-quoted.
- collections := resolveQmdCollections(qmdConfig)
- for _, col := range collections {
+ // Register each checkout-based collection.
+ // The workdir is double-quoted to preserve ${GITHUB_WORKSPACE} variable expansion.
+ // User-provided names and globs are POSIX single-quoted to prevent shell injection.
+ checkouts := resolveQmdCheckouts(qmdConfig)
+ for _, col := range checkouts {
var globArg string
if len(col.docs) > 0 {
globArg = shellSingleQuote(strings.Join(col.docs, ","))
@@ -223,6 +291,11 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
))
}
+ // Emit a step per GitHub search entry
+ for i, search := range qmdConfig.Searches {
+ steps = append(steps, generateQmdSearchStep(search, i))
+ }
+
// Upload qmd index as a separate artifact for the agent job
qmdLog.Print("Adding qmd index artifact upload step")
qmdArtifactName := artifactPrefixExprForActivationJob(data) + constants.QmdArtifactName
diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go
index 15e4156f77b..be39b910881 100644
--- a/pkg/workflow/tools_parser.go
+++ b/pkg/workflow/tools_parser.go
@@ -334,36 +334,81 @@ func parsePlaywrightTool(val any) *PlaywrightToolConfig {
}
// parseQmdTool converts raw qmd tool configuration to QmdToolConfig.
-// The qmd tool supports two forms:
-// - Simple: docs field with a list of glob patterns (single collection, current repo)
-// - Extended: collections field with named collections, each optionally with a checkout
+// Supported forms (from most to least preferred):
+//
+// 1. New structured form:
+// checkouts: list of named collections (with optional checkout per entry)
+// searches: list of GitHub search queries
+//
+// 2. Legacy extended form (backward-compatible):
+// collections: list of named collections (treated as checkouts)
+//
+// 3. Legacy simple form (backward-compatible):
+// docs: list of glob patterns (single collection, current repository)
func parseQmdTool(val any) *QmdToolConfig {
if val == nil {
- toolsParserLog.Print("qmd tool enabled with empty docs configuration")
+ toolsParserLog.Print("qmd tool enabled with empty configuration")
return &QmdToolConfig{}
}
if configMap, ok := val.(map[string]any); ok {
config := &QmdToolConfig{}
- // Handle collections field - list of named collections with optional checkout
+ // Handle checkouts field (new structured form)
+ if checkoutsValue, ok := configMap["checkouts"]; ok {
+ if arr, ok := checkoutsValue.([]any); ok {
+ config.Checkouts = make([]*QmdDocCollection, 0, len(arr))
+ for i, item := range arr {
+ itemMap, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ col := parseQmdDocCollection(itemMap, i)
+ config.Checkouts = append(config.Checkouts, col)
+ }
+ toolsParserLog.Printf("qmd tool parsed %d checkouts", len(config.Checkouts))
+ }
+ }
+
+ // Handle searches field (new structured form)
+ if searchesValue, ok := configMap["searches"]; ok {
+ if arr, ok := searchesValue.([]any); ok {
+ config.Searches = make([]*QmdSearchEntry, 0, len(arr))
+ for _, item := range arr {
+ itemMap, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ entry := parseQmdSearchEntry(itemMap)
+ config.Searches = append(config.Searches, entry)
+ }
+ toolsParserLog.Printf("qmd tool parsed %d searches", len(config.Searches))
+ }
+ }
+
+ // If either new key was found, return now (new form takes precedence)
+ if len(config.Checkouts) > 0 || len(config.Searches) > 0 {
+ return config
+ }
+
+ // Legacy: handle collections field (treated as checkouts for backward compat)
if collectionsValue, ok := configMap["collections"]; ok {
if arr, ok := collectionsValue.([]any); ok {
- config.Collections = make([]*QmdDocCollection, 0, len(arr))
+ config.Checkouts = make([]*QmdDocCollection, 0, len(arr))
for i, item := range arr {
itemMap, ok := item.(map[string]any)
if !ok {
continue
}
col := parseQmdDocCollection(itemMap, i)
- config.Collections = append(config.Collections, col)
+ config.Checkouts = append(config.Checkouts, col)
}
- toolsParserLog.Printf("qmd tool parsed %d collections", len(config.Collections))
+ toolsParserLog.Printf("qmd tool parsed %d legacy collections (mapped to checkouts)", len(config.Checkouts))
return config
}
}
- // Handle docs field - simple glob list (backward-compatible single collection)
+ // Legacy: handle docs field - simple glob list (single collection, current repo)
if docsValue, ok := configMap["docs"]; ok {
if arr, ok := docsValue.([]any); ok {
config.Docs = make([]string, 0, len(arr))
@@ -420,6 +465,47 @@ func parseQmdDocCollection(m map[string]any, index int) *QmdDocCollection {
return col
}
+// parseQmdSearchEntry converts a raw map to a QmdSearchEntry.
+func parseQmdSearchEntry(m map[string]any) *QmdSearchEntry {
+ entry := &QmdSearchEntry{}
+
+ if q, ok := m["query"].(string); ok {
+ entry.Query = q
+ }
+ entry.Min = parseYAMLInt(m["min"])
+ entry.Max = parseYAMLInt(m["max"])
+
+ if token, ok := m["github-token"].(string); ok {
+ entry.GitHubToken = token
+ }
+
+ if appMap, ok := m["github-app"].(map[string]any); ok {
+ entry.GitHubApp = parseAppConfig(appMap)
+ }
+
+ return entry
+}
+
+// parseYAMLInt converts a YAML-unmarshaled numeric value to int.
+// goccy/go-yaml unmarshals integers as uint64; standard yaml/v3 uses int.
+// float64 is also handled for completeness.
+func parseYAMLInt(v any) int {
+ if v == nil {
+ return 0
+ }
+ switch n := v.(type) {
+ case int:
+ return n
+ case int64:
+ return int(n)
+ case uint64:
+ return int(n)
+ case float64:
+ return int(n)
+ }
+ return 0
+}
+
// parseSerenaTool converts raw serena tool configuration to SerenaToolConfig
func parseSerenaTool(val any) *SerenaToolConfig {
if val == nil {
diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go
index 6fca0911d9e..ec3284795e8 100644
--- a/pkg/workflow/tools_types.go
+++ b/pkg/workflow/tools_types.go
@@ -329,21 +329,58 @@ type QmdDocCollection struct {
Checkout *CheckoutConfig `yaml:"checkout,omitempty"`
}
+// QmdSearchEntry represents a single GitHub search query whose results are
+// downloaded and added to the qmd index as individual files.
+type QmdSearchEntry struct {
+ // Query is the GitHub code/content search query string.
+ // Example: "repo:owner/repo language:Markdown path:docs/"
+ Query string `yaml:"query"`
+
+ // Min is the minimum number of search results required. If fewer results
+ // are returned the step fails with an error.
+ Min int `yaml:"min,omitempty"`
+
+ // Max is the maximum number of search results to download.
+ // Defaults to 30 when not set.
+ Max int `yaml:"max,omitempty"`
+
+ // GitHubToken overrides the default GITHUB_TOKEN used to authenticate
+ // the GitHub search API request.
+ // Mutually exclusive with GitHubApp.
+ GitHubToken string `yaml:"github-token,omitempty"`
+
+ // GitHubApp configures GitHub App-based authentication for the search request.
+ // Mutually exclusive with GitHubToken.
+ GitHubApp *GitHubAppConfig `yaml:"github-app,omitempty"`
+}
+
// QmdToolConfig represents the configuration for the qmd documentation search tool.
// qmd (https://github.com/tobi/qmd) provides local vector search over documentation files.
// The index is built in the activation job and downloaded by the agent job, so no
// contents:read permission is needed in the agent job.
+//
+// Two sources can contribute to the index:
+//
+// 1. checkouts – glob-based collections from checked-out repositories
+// 2. searches – GitHub search queries whose results are downloaded as files
+//
+// Legacy shorthand: docs and collections fields are still accepted for backward
+// compatibility and are treated as-if under checkouts.
type QmdToolConfig struct {
- // Docs is the list of glob patterns for files to include in the search index.
+ // Docs is the legacy list of glob patterns for files to include in the search index.
// Shorthand for a single default collection targeting the current repository.
- // Mutually exclusive with Collections.
+ // When Checkouts is also set, Docs is ignored.
// Example: ["docs/**/*.md", ".github/**/*.md"]
Docs []string `yaml:"docs,omitempty"`
- // Collections is the list of named documentation collections.
+ // Checkouts is the list of named documentation collections.
// Each collection can specify its own checkout to target a different repository.
- // When both Docs and Collections are set, Collections takes precedence and Docs is ignored.
- Collections []*QmdDocCollection `yaml:"collections,omitempty"`
+ // When set, Docs is ignored.
+ Checkouts []*QmdDocCollection `yaml:"checkouts,omitempty"`
+
+ // Searches is the list of GitHub search queries whose results are downloaded
+ // and added to the qmd index.
+ Searches []*QmdSearchEntry `yaml:"searches,omitempty"`
}
// SerenaToolConfig represents the configuration for the Serena MCP tool
From ef071a06044862d7d98d1a7d08dbfbd0afe7d6e0 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Mar 2026 22:44:46 +0000
Subject: [PATCH 06/49] Add cache-key field to qmd config for GitHub Actions
cache support
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/9b048214-2eee-4a65-b213-30364c2769b7
---
pkg/parser/schemas/main_workflow_schema.json | 17 ++
pkg/workflow/qmd.go | 165 ++++++++++++++-----
pkg/workflow/tools_parser.go | 12 ++
pkg/workflow/tools_types.go | 14 ++
4 files changed, 166 insertions(+), 42 deletions(-)
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 39aa75ebc40..883816f3ebd 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -3394,6 +3394,11 @@
"$ref": "#/$defs/qmdCollection"
},
"minItems": 1
+ },
+ "cache-key": {
+ "type": "string",
+ "description": "GitHub Actions cache key used to persist the qmd index across workflow runs. When set without any indexing sources (checkouts/searches/docs), qmd operates in read-only mode: the index is restored from cache and all indexing steps are skipped.",
+ "examples": ["qmd-index-${{ hashFiles('docs/**') }}", "qmd-index-v1"]
}
},
"additionalProperties": false,
@@ -3441,6 +3446,18 @@
"github-token": "${{ secrets.GITHUB_TOKEN }}"
}
]
+ },
+ {
+ "cache-key": "qmd-index-${{ hashFiles('docs/**') }}"
+ },
+ {
+ "checkouts": [
+ {
+ "name": "docs",
+ "docs": ["docs/**/*.md"]
+ }
+ ],
+ "cache-key": "qmd-index-${{ hashFiles('docs/**') }}"
}
]
},
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index abf5791e15b..f24c6c3c0b9 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -23,6 +23,11 @@
// its own checkout config to target a different repo)
// - searches: GitHub search queries whose results are downloaded and added to the index
//
+// Optionally, a cache-key can be set to persist the index in GitHub Actions cache:
+//
+// - cache-key only (read-only mode): the index is restored from cache; no indexing steps run
+// - cache-key + sources: index is built if cache miss, then saved to cache for future runs
+//
// Example frontmatter:
//
// tools:
@@ -36,6 +41,7 @@
// min: 1
// max: 30
// github-token: ${{ secrets.GITHUB_TOKEN }}
+// cache-key: "qmd-index-${{ hashFiles('docs/**') }}"
//
// # Artifact lifecycle
//
@@ -78,6 +84,40 @@ func hasQmdTool(parsedTools *Tools) bool {
return parsedTools.Qmd != nil
}
+// qmdHasSources reports whether the qmd config has any indexing sources
+// (checkouts, searches, or legacy docs). When false and a cache-key is set,
+// qmd operates in read-only mode: the index is restored from cache only.
+func qmdHasSources(qmdConfig *QmdToolConfig) bool {
+ return len(qmdConfig.Checkouts) > 0 || len(qmdConfig.Searches) > 0 || len(qmdConfig.Docs) > 0
+}
+
+// generateQmdCacheRestoreStep generates an activation-job step that restores the qmd index
+// from GitHub Actions cache. The step ID is "qmd-cache-restore" so that subsequent steps
+// can check cache-hit via steps.qmd-cache-restore.outputs.cache-hit.
+func generateQmdCacheRestoreStep(cacheKey string) string {
+ var sb strings.Builder
+ sb.WriteString(" - name: Restore qmd index from cache\n")
+ sb.WriteString(" id: qmd-cache-restore\n")
+ fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/restore"))
+ sb.WriteString(" with:\n")
+ fmt.Fprintf(&sb, " key: %s\n", cacheKey)
+ sb.WriteString(" path: /tmp/gh-aw/qmd-index/\n")
+ return sb.String()
+}
+
+// generateQmdCacheSaveStep generates an activation-job step that saves the qmd index to
+// GitHub Actions cache. It only runs when the preceding cache-restore step was a miss.
+func generateQmdCacheSaveStep(cacheKey string) string {
+ var sb strings.Builder
+ sb.WriteString(" - name: Save qmd index to cache\n")
+ sb.WriteString(" if: steps.qmd-cache-restore.outputs.cache-hit != 'true'\n")
+ fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/save"))
+ sb.WriteString(" with:\n")
+ fmt.Fprintf(&sb, " key: %s\n", cacheKey)
+ sb.WriteString(" path: /tmp/gh-aw/qmd-index/\n")
+ return sb.String()
+}
+
// resolvedQmdCollection is an internal representation of a qmd collection
// with its working directory resolved.
type resolvedQmdCollection struct {
@@ -241,59 +281,100 @@ func generateQmdSearchStep(entry *QmdSearchEntry, index int) string {
// generateQmdIndexSteps generates the activation job steps that install qmd, register
// collections for each configured checkout and/or search, and build the vector search index.
// The index is stored at /tmp/gh-aw/qmd-index and uploaded as the qmd-index artifact.
+//
+// When qmdConfig.CacheKey is set:
+// - A cache restore step is always emitted first.
+// - In read-only mode (no sources): only the cache restore + artifact upload are emitted;
+// Node.js, qmd installation, and indexing steps are skipped entirely.
+// - In build mode (sources present): indexing steps are guarded by
+// `if: steps.qmd-cache-restore.outputs.cache-hit != 'true'`, so they are skipped on a
+// cache hit. A cache save step follows the indexing steps.
func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []string {
- qmdLog.Printf("Generating qmd index steps: docs=%v checkouts=%d searches=%d",
- qmdConfig.Docs, len(qmdConfig.Checkouts), len(qmdConfig.Searches))
+ hasSources := qmdHasSources(qmdConfig)
+ isCacheOnlyMode := qmdConfig.CacheKey != "" && !hasSources
+ qmdLog.Printf("Generating qmd index steps: docs=%v checkouts=%d searches=%d cacheKey=%q cacheOnly=%v",
+ qmdConfig.Docs, len(qmdConfig.Checkouts), len(qmdConfig.Searches), qmdConfig.CacheKey, isCacheOnlyMode)
version := string(constants.DefaultQmdVersion)
var steps []string
- // Setup Node.js (required to run npm/npx)
- steps = append(steps, " - name: Setup Node.js for qmd\n")
- steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/setup-node")))
- steps = append(steps, " with:\n")
- steps = append(steps, fmt.Sprintf(" node-version: \"%s\"\n", string(constants.DefaultNodeVersion)))
+ // If a cache-key is set, always restore first (both cache-only and build modes)
+ if qmdConfig.CacheKey != "" {
+ steps = append(steps, generateQmdCacheRestoreStep(qmdConfig.CacheKey))
+ }
- // Install qmd globally
- steps = append(steps, " - name: Install qmd\n")
- steps = append(steps, " run: |\n")
- steps = append(steps, fmt.Sprintf(" npm install -g @tobilu/qmd@%s\n", version))
+ // Cache-only mode: no indexing at all — just use the restored cache
+ if isCacheOnlyMode {
+ qmdLog.Print("qmd cache-only mode: skipping indexing, using cache only")
+ // Fall through to artifact upload below
+ } else {
+ // Conditional prefix for build steps when cache-key is set (skip on cache hit)
+ var ifCacheMiss string
+ if qmdConfig.CacheKey != "" {
+ ifCacheMiss = " if: steps.qmd-cache-restore.outputs.cache-hit != 'true'\n"
+ }
- // Emit a checkout step for each checkout-based collection that needs its own repo
- for _, col := range qmdConfig.Checkouts {
- if checkoutStep := generateQmdCollectionCheckoutStep(col); checkoutStep != "" {
- steps = append(steps, checkoutStep)
+ // Setup Node.js (required to run npm/npx)
+ steps = append(steps, " - name: Setup Node.js for qmd\n")
+ if ifCacheMiss != "" {
+ steps = append(steps, ifCacheMiss)
}
- }
+ steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/setup-node")))
+ steps = append(steps, " with:\n")
+ steps = append(steps, fmt.Sprintf(" node-version: \"%s\"\n", string(constants.DefaultNodeVersion)))
- // Build the index: create the cache dir and register all collections
- steps = append(steps, " - name: Build qmd index\n")
- steps = append(steps, " run: |\n")
- steps = append(steps, " set -e\n")
- steps = append(steps, " mkdir -p /tmp/gh-aw/qmd-index\n")
-
- // Register each checkout-based collection.
- // The workdir is double-quoted to preserve ${GITHUB_WORKSPACE} variable expansion.
- // User-provided names and globs are POSIX single-quoted to prevent shell injection.
- checkouts := resolveQmdCheckouts(qmdConfig)
- for _, col := range checkouts {
- var globArg string
- if len(col.docs) > 0 {
- globArg = shellSingleQuote(strings.Join(col.docs, ","))
- } else {
- globArg = "'**/*.md'"
+ // Install qmd globally
+ steps = append(steps, " - name: Install qmd\n")
+ if ifCacheMiss != "" {
+ steps = append(steps, ifCacheMiss)
+ }
+ steps = append(steps, " run: |\n")
+ steps = append(steps, fmt.Sprintf(" npm install -g @tobilu/qmd@%s\n", version))
+
+ // Emit a checkout step for each checkout-based collection that needs its own repo
+ for _, col := range qmdConfig.Checkouts {
+ if checkoutStep := generateQmdCollectionCheckoutStep(col); checkoutStep != "" {
+ steps = append(steps, checkoutStep)
+ }
}
- steps = append(steps, fmt.Sprintf(
- " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"%s\" --name %s --glob %s\n",
- col.workdir,
- shellSingleQuote(col.name),
- globArg,
- ))
- }
- // Emit a step per GitHub search entry
- for i, search := range qmdConfig.Searches {
- steps = append(steps, generateQmdSearchStep(search, i))
+ // Build the index: create the cache dir and register all collections
+ steps = append(steps, " - name: Build qmd index\n")
+ if ifCacheMiss != "" {
+ steps = append(steps, ifCacheMiss)
+ }
+ steps = append(steps, " run: |\n")
+ steps = append(steps, " set -e\n")
+ steps = append(steps, " mkdir -p /tmp/gh-aw/qmd-index\n")
+
+ // Register each checkout-based collection.
+ // The workdir is double-quoted to preserve ${GITHUB_WORKSPACE} variable expansion.
+ // User-provided names and globs are POSIX single-quoted to prevent shell injection.
+ checkouts := resolveQmdCheckouts(qmdConfig)
+ for _, col := range checkouts {
+ var globArg string
+ if len(col.docs) > 0 {
+ globArg = shellSingleQuote(strings.Join(col.docs, ","))
+ } else {
+ globArg = "'**/*.md'"
+ }
+ steps = append(steps, fmt.Sprintf(
+ " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"%s\" --name %s --glob %s\n",
+ col.workdir,
+ shellSingleQuote(col.name),
+ globArg,
+ ))
+ }
+
+ // Emit a step per GitHub search entry
+ for i, search := range qmdConfig.Searches {
+ steps = append(steps, generateQmdSearchStep(search, i))
+ }
+
+ // If cache-key is set, save the freshly-built index to cache (skipped on hit)
+ if qmdConfig.CacheKey != "" {
+ steps = append(steps, generateQmdCacheSaveStep(qmdConfig.CacheKey))
+ }
}
// Upload qmd index as a separate artifact for the agent job
diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go
index be39b910881..5af74428044 100644
--- a/pkg/workflow/tools_parser.go
+++ b/pkg/workflow/tools_parser.go
@@ -339,6 +339,7 @@ func parsePlaywrightTool(val any) *PlaywrightToolConfig {
// 1. New structured form:
// checkouts: list of named collections (with optional checkout per entry)
// searches: list of GitHub search queries
+// cache-key: optional GitHub Actions cache key
//
// 2. Legacy extended form (backward-compatible):
// collections: list of named collections (treated as checkouts)
@@ -354,6 +355,12 @@ func parseQmdTool(val any) *QmdToolConfig {
if configMap, ok := val.(map[string]any); ok {
config := &QmdToolConfig{}
+ // Handle cache-key field (applies to all forms)
+ if cacheKey, ok := configMap["cache-key"].(string); ok && cacheKey != "" {
+ config.CacheKey = cacheKey
+ toolsParserLog.Printf("qmd tool cache-key: %s", cacheKey)
+ }
+
// Handle checkouts field (new structured form)
if checkoutsValue, ok := configMap["checkouts"]; ok {
if arr, ok := checkoutsValue.([]any); ok {
@@ -391,6 +398,11 @@ func parseQmdTool(val any) *QmdToolConfig {
return config
}
+ // Return early if cache-key-only (read-only mode — no indexing sources)
+ if config.CacheKey != "" {
+ return config
+ }
+
// Legacy: handle collections field (treated as checkouts for backward compat)
if collectionsValue, ok := configMap["collections"]; ok {
if arr, ok := collectionsValue.([]any); ok {
diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go
index ec3284795e8..a3657cd6a2c 100644
--- a/pkg/workflow/tools_types.go
+++ b/pkg/workflow/tools_types.go
@@ -364,6 +364,10 @@ type QmdSearchEntry struct {
// 1. checkouts – glob-based collections from checked-out repositories
// 2. searches – GitHub search queries whose results are downloaded as files
//
+// Optionally, the index can be cached in GitHub Actions cache using the cache-key field.
+// When cache-key is set without any sources (checkouts/searches/docs), qmd operates in
+// read-only mode: it restores the index from cache and skips all indexing steps.
+//
// Legacy shorthand: docs and collections fields are still accepted for backward
// compatibility and are treated as-if under checkouts.
type QmdToolConfig struct {
@@ -381,6 +385,16 @@ type QmdToolConfig struct {
// Searches is the list of GitHub search queries whose results are downloaded
// and added to the qmd index.
Searches []*QmdSearchEntry `yaml:"searches,omitempty"`
+
+ // CacheKey is an optional GitHub Actions cache key used to persist the qmd index
+ // across workflow runs. When set:
+ // - If sources (checkouts/searches/docs) are also configured: the index is built
+ // normally and then saved to the cache. On subsequent runs, the cached index is
+ // restored and the build steps are skipped if the cache hit is exact.
+ // - If no sources are configured (read-only mode): the index is restored directly
+ // from cache without any indexing steps.
+ // Example: "qmd-index-${{ hashFiles('docs/**') }}"
+ CacheKey string `yaml:"cache-key,omitempty"`
}
// SerenaToolConfig represents the configuration for the Serena MCP tool
From 1207c52898ca8747ef64797ff802b0433c4fffda Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Mar 2026 22:54:50 +0000
Subject: [PATCH 07/49] Add qmd reference documentation page and tools.md entry
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ad6ae84d-6e38-4ef1-85f1-460c1020189c
---
docs/src/content/docs/reference/qmd.md | 198 +++++++++++++++++++++++
docs/src/content/docs/reference/tools.md | 14 ++
2 files changed, 212 insertions(+)
create mode 100644 docs/src/content/docs/reference/qmd.md
diff --git a/docs/src/content/docs/reference/qmd.md b/docs/src/content/docs/reference/qmd.md
new file mode 100644
index 00000000000..5fc170cf743
--- /dev/null
+++ b/docs/src/content/docs/reference/qmd.md
@@ -0,0 +1,198 @@
+---
+title: QMD Documentation Search
+description: Build a local vector search index over documentation files and expose it as an MCP tool so agents can find relevant docs without contents:read permission in the agent job.
+sidebar:
+ order: 730
+---
+
+import { Aside } from "@astrojs/starlight/components";
+
+
+
+The `qmd:` tool integrates [tobi/qmd](https://github.com/tobi/qmd) as a built-in MCP server that performs **vector similarity search** over documentation files. The search index is built in the activation job (which already has `contents: read`) and shared with the agent job via a GitHub Actions artifact, so the agent job does not need `contents: read`.
+
+## How it works
+
+1. **Activation job** — installs `@tobilu/qmd`, registers documentation collections from configured checkouts and/or GitHub searches, builds the vector index, and uploads it as the `qmd-index` artifact.
+2. **Agent job** — downloads the `qmd-index` artifact and starts qmd as an MCP server (`npx @tobilu/qmd serve-mcp`). The agent can call the `search` tool to find relevant documentation files by natural language query.
+
+## Quick start
+
+```aw wrap
+---
+tools:
+ qmd:
+ docs:
+ - docs/**/*.md
+ - .github/**/*.md
+---
+```
+
+This indexes all markdown files under `docs/` and `.github/` in the current repository.
+
+## Configuration
+
+### Simple form
+
+Index files from the current repository using glob patterns:
+
+```yaml wrap
+tools:
+ qmd:
+ docs:
+ - docs/**/*.md
+ - "**/*.mdx"
+```
+
+### Checkouts form
+
+Index files from one or more named collections, each with an optional repository checkout:
+
+```yaml wrap
+tools:
+ qmd:
+ checkouts:
+ - name: current-docs
+ docs:
+ - docs/**/*.md
+ - name: other-repo-docs
+ docs:
+ - docs/**/*.md
+ checkout:
+ repository: owner/other-repo
+ ref: main
+ path: ./other-repo # optional; defaults to /tmp/gh-aw/qmd-checkout-
+```
+
+Each `checkout:` entry accepts the same options as the top-level [`checkout:`](/gh-aw/reference/frontmatter/#checkout) field: `repository`, `ref`, `path`, `token`, `fetch-depth`, `sparse-checkout`, `submodules`, and `lfs`.
+
+### Searches form
+
+Download files returned by GitHub code search and add them to the index:
+
+```yaml wrap
+tools:
+ qmd:
+ searches:
+ - query: "repo:owner/repo language:Markdown path:docs/"
+ min: 1 # fail the activation job if fewer results (default: 0)
+ max: 30 # download at most this many files (default: 30)
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+```
+
+Each search entry runs `gh search code ` in the activation job, downloads every matching file via the GitHub API, and registers the result as a separate qmd collection named `search-0`, `search-1`, etc.
+
+Use `github-app:` instead of `github-token:` for cross-organization access:
+
+```yaml wrap
+tools:
+ qmd:
+ searches:
+ - query: "org:myorg language:Markdown path:docs/"
+ github-app:
+ app-id: ${{ vars.APP_ID }}
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
+```
+
+### Cache key
+
+Persist the index in GitHub Actions cache to speed up subsequent runs. On a cache hit all indexing steps are skipped automatically:
+
+```yaml wrap
+tools:
+ qmd:
+ checkouts:
+ - name: docs
+ docs: [docs/**/*.md]
+ cache-key: "qmd-index-${{ hashFiles('docs/**') }}"
+```
+
+#### Read-only mode
+
+When `cache-key` is set without any indexing sources (`checkouts`, `searches`, or `docs`), the tool operates in **read-only mode**: the activation job restores the index from cache (failing silently if the cache does not exist yet) and skips all Node.js, npm, and qmd build steps entirely. This is useful for maintaining a shared, pre-built documentation database:
+
+```yaml wrap
+tools:
+ qmd:
+ cache-key: "qmd-index-v1"
+```
+
+### Combined form
+
+All sources can be combined in a single configuration:
+
+```yaml wrap
+tools:
+ qmd:
+ checkouts:
+ - name: local-docs
+ docs: [docs/**/*.md]
+ - name: sdk-docs
+ docs: [README.md, docs/**/*.md]
+ checkout:
+ repository: owner/sdk
+ path: ./sdk
+ searches:
+ - query: "org:myorg language:Markdown path:wiki/"
+ max: 50
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ cache-key: "qmd-index-${{ hashFiles('docs/**') }}"
+```
+
+## Configuration reference
+
+### `qmd:` fields
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `docs` | `string[]` | No | Glob patterns for files in the current repository. Shorthand for a single default collection; ignored when `checkouts` is set. |
+| `checkouts` | `QmdDocCollection[]` | No | Named collections, each with optional per-collection checkout. |
+| `searches` | `QmdSearchEntry[]` | No | GitHub code search queries whose results are downloaded and indexed. |
+| `cache-key` | `string` | No | GitHub Actions cache key for persisting the index across runs. When set without sources, enables read-only mode. |
+
+### `QmdDocCollection` fields
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | `string` | No | Collection identifier (defaults to `"docs"`). |
+| `docs` | `string[]` | No | Glob patterns for files to include (defaults to `**/*.md`). |
+| `checkout` | `CheckoutConfig` | No | Repository checkout options — same syntax as the top-level [`checkout:`](/gh-aw/reference/frontmatter/#checkout) field. Defaults to the current repository. |
+
+### `QmdSearchEntry` fields
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `query` | `string` | Yes | GitHub code search query string (e.g., `"repo:owner/repo language:Markdown"`). |
+| `min` | `int` | No | Minimum number of results required; fails the activation job if not met (default: `0`). |
+| `max` | `int` | No | Maximum number of files to download (default: `30`). |
+| `github-token` | `string` | No | GitHub token for authenticated search (e.g., `${{ secrets.GITHUB_TOKEN }}`). |
+| `github-app` | `GitHubAppConfig` | No | GitHub App credentials for cross-organization access. |
+
+## Permissions
+
+The `qmd` tool does **not** require `contents: read` in the agent job. All file access happens in the activation job, which already has that permission.
+
+```yaml wrap
+# No extra permissions needed for the agent job
+permissions:
+ contents: read # activation job only — already present by default
+```
+
+## Agent usage
+
+When qmd is active, the agent's system prompt instructs it to use the `search` tool before falling back to file listing or `bash`. Example queries:
+
+- `"how to configure MCP servers"` — finds docs about MCP setup
+- `"safe-outputs create-pull-request options"` — finds safe-output option reference
+- `"permissions frontmatter field"` — finds permission configuration docs
+
+The tool returns file paths ranked by relevance. Use standard file reading to fetch full content.
+
+## Related Documentation
+
+- [Tools](/gh-aw/reference/tools/) - Overview of all built-in tools
+- [Frontmatter](/gh-aw/reference/frontmatter/#checkout) - Top-level checkout configuration
+- [Permissions](/gh-aw/reference/permissions/) - GitHub Actions permission configuration
+- [Dependabot](/gh-aw/reference/dependabot/) - Automatic dependency updates (tracks `@tobilu/qmd` version)
diff --git a/docs/src/content/docs/reference/tools.md b/docs/src/content/docs/reference/tools.md
index 70b1e3a1880..972d21b69b3 100644
--- a/docs/src/content/docs/reference/tools.md
+++ b/docs/src/content/docs/reference/tools.md
@@ -100,6 +100,19 @@ tools:
See **[Repo Memory Reference](/gh-aw/reference/repo-memory/)** for complete configuration options and usage examples.
+### QMD Documentation Search (`qmd:`) — Experimental
+
+Build a local vector search index over documentation files and expose it as an MCP search tool. The index is built in the activation job (no `contents: read` needed in the agent job):
+
+```yaml wrap
+tools:
+ qmd:
+ docs:
+ - docs/**/*.md
+```
+
+See **[QMD Reference](/gh-aw/reference/qmd/)** for complete configuration options, checkout support, GitHub search integration, and cache key usage.
+
### Introspection on Agentic Workflows (`agentic-workflows:`)
Provides workflow introspection, log analysis, and debugging tools. Requires `actions: read` permission:
@@ -147,6 +160,7 @@ mcp-servers:
- [Playwright](/gh-aw/reference/playwright/) - Browser automation and testing configuration
- [Cache Memory](/gh-aw/reference/cache-memory/) - Persistent memory across workflow runs
- [Repo Memory](/gh-aw/reference/repo-memory/) - Repository-specific memory storage
+- [QMD Documentation Search](/gh-aw/reference/qmd/) - Vector similarity search over documentation files
- [MCP Scripts](/gh-aw/reference/mcp-scripts/) - Define custom inline tools with JavaScript or shell scripts
- [Frontmatter](/gh-aw/reference/frontmatter/) - All frontmatter configuration options
- [Network Permissions](/gh-aw/reference/network/) - Network access control for AI engines
From 0b53db3059e3006b65fd2ba396fc7b5a9798ad19 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Mar 2026 23:03:01 +0000
Subject: [PATCH 08/49] Add actions/cache for qmd models directory in
activation and agent jobs
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/61e87d3a-8f40-404a-927f-49c0961aa1ef
---
docs/src/content/docs/reference/qmd.md | 2 ++
pkg/workflow/compiler_yaml_main_job.go | 2 ++
pkg/workflow/qmd.go | 18 ++++++++++++++++++
3 files changed, 22 insertions(+)
diff --git a/docs/src/content/docs/reference/qmd.md b/docs/src/content/docs/reference/qmd.md
index 5fc170cf743..8a342222d9f 100644
--- a/docs/src/content/docs/reference/qmd.md
+++ b/docs/src/content/docs/reference/qmd.md
@@ -18,6 +18,8 @@ The `qmd:` tool integrates [tobi/qmd](https://github.com/tobi/qmd) as a built-in
1. **Activation job** — installs `@tobilu/qmd`, registers documentation collections from configured checkouts and/or GitHub searches, builds the vector index, and uploads it as the `qmd-index` artifact.
2. **Agent job** — downloads the `qmd-index` artifact and starts qmd as an MCP server (`npx @tobilu/qmd serve-mcp`). The agent can call the `search` tool to find relevant documentation files by natural language query.
+The embedding models used to build and query the index are automatically cached in both jobs via `actions/cache` (keyed by OS at `~/.cache/qmd/models/`), so models are only downloaded once per runner OS.
+
## Quick start
```aw wrap
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index aa527faf54f..17ef4e69f54 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -284,6 +284,8 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
if data.QmdConfig != nil {
compilerYamlLog.Print("Adding qmd index download step")
yaml.WriteString(generateQmdDownloadStep(data))
+ compilerYamlLog.Print("Adding qmd models cache step")
+ yaml.WriteString(generateQmdModelsCacheStep())
}
// GH_AW_SAFE_OUTPUTS is now set at job level, no setup step needed
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index f24c6c3c0b9..4e256eb49b9 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -91,6 +91,21 @@ func qmdHasSources(qmdConfig *QmdToolConfig) bool {
return len(qmdConfig.Checkouts) > 0 || len(qmdConfig.Searches) > 0 || len(qmdConfig.Docs) > 0
}
+// generateQmdModelsCacheStep generates a step that caches the qmd embedding models directory
+// (~/.cache/qmd/models/). It uses the combined actions/cache action (restore + post-save),
+// keyed by OS so that the cached models are compatible with the runner architecture.
+// This step should be emitted in both the activation job (before index building) and the
+// agent job (before the qmd MCP server starts) to avoid re-downloading models on each run.
+func generateQmdModelsCacheStep() string {
+ var sb strings.Builder
+ sb.WriteString(" - name: Cache qmd models\n")
+ fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache"))
+ sb.WriteString(" with:\n")
+ sb.WriteString(" path: ~/.cache/qmd/models/\n")
+ sb.WriteString(" key: qmd-models-${{ runner.os }}\n")
+ return sb.String()
+}
+
// generateQmdCacheRestoreStep generates an activation-job step that restores the qmd index
// from GitHub Actions cache. The step ID is "qmd-cache-restore" so that subsequent steps
// can check cache-hit via steps.qmd-cache-restore.outputs.cache-hit.
@@ -303,6 +318,9 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
steps = append(steps, generateQmdCacheRestoreStep(qmdConfig.CacheKey))
}
+ // Always cache qmd embedding models to avoid re-downloading on each run
+ steps = append(steps, generateQmdModelsCacheStep())
+
// Cache-only mode: no indexing at all — just use the restored cache
if isCacheOnlyMode {
qmdLog.Print("qmd cache-only mode: skipping indexing, using cache only")
From 13c5bfc1c002f2ccce0ab46aefcf13c64b312131 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 00:05:03 +0000
Subject: [PATCH 09/49] Rename docs to paths in QmdDocCollection, add context
field, generate step summary
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/3e6b6f99-104f-414e-8da7-da70bb29cabb
---
docs/src/content/docs/reference/qmd.md | 19 ++-
pkg/parser/schemas/main_workflow_schema.json | 25 +++-
pkg/workflow/qmd.go | 116 ++++++++++++++++---
pkg/workflow/tools_parser.go | 23 +++-
pkg/workflow/tools_types.go | 9 +-
5 files changed, 159 insertions(+), 33 deletions(-)
diff --git a/docs/src/content/docs/reference/qmd.md b/docs/src/content/docs/reference/qmd.md
index 8a342222d9f..74fae882ea4 100644
--- a/docs/src/content/docs/reference/qmd.md
+++ b/docs/src/content/docs/reference/qmd.md
@@ -57,11 +57,13 @@ tools:
qmd:
checkouts:
- name: current-docs
- docs:
+ paths:
- docs/**/*.md
+ context: "Project documentation"
- name: other-repo-docs
- docs:
+ paths:
- docs/**/*.md
+ context: "Documentation for owner/other-repo"
checkout:
repository: owner/other-repo
ref: main
@@ -70,6 +72,8 @@ tools:
Each `checkout:` entry accepts the same options as the top-level [`checkout:`](/gh-aw/reference/frontmatter/#checkout) field: `repository`, `ref`, `path`, `token`, `fetch-depth`, `sparse-checkout`, `submodules`, and `lfs`.
+The optional `context:` field provides additional hints to the agent about the collection's content (e.g. product area, audience, or version).
+
### Searches form
Download files returned by GitHub code search and add them to the index:
@@ -107,7 +111,7 @@ tools:
qmd:
checkouts:
- name: docs
- docs: [docs/**/*.md]
+ paths: [docs/**/*.md]
cache-key: "qmd-index-${{ hashFiles('docs/**') }}"
```
@@ -130,9 +134,11 @@ tools:
qmd:
checkouts:
- name: local-docs
- docs: [docs/**/*.md]
+ paths: [docs/**/*.md]
+ context: "Project documentation"
- name: sdk-docs
- docs: [README.md, docs/**/*.md]
+ paths: [README.md, docs/**/*.md]
+ context: "SDK reference"
checkout:
repository: owner/sdk
path: ./sdk
@@ -159,7 +165,8 @@ tools:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | `string` | No | Collection identifier (defaults to `"docs"`). |
-| `docs` | `string[]` | No | Glob patterns for files to include (defaults to `**/*.md`). |
+| `paths` | `string[]` | No | Glob patterns for files to include (defaults to `**/*.md`). The legacy key `docs` is also accepted. |
+| `context` | `string` | No | Optional context hint for the agent about this collection's content (e.g. `"GitHub Actions documentation"`). |
| `checkout` | `CheckoutConfig` | No | Repository checkout options — same syntax as the top-level [`checkout:`](/gh-aw/reference/frontmatter/#checkout) field. Defaults to the current repository. |
### `QmdSearchEntry` fields
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 883816f3ebd..6871ce90f65 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -3410,11 +3410,12 @@
"checkouts": [
{
"name": "current-docs",
- "docs": ["docs/**/*.md"]
+ "paths": ["docs/**/*.md"]
},
{
"name": "other-docs",
- "docs": ["docs/**/*.md"],
+ "paths": ["docs/**/*.md"],
+ "context": "Documentation for owner/other-repo",
"checkout": {
"repository": "owner/other-repo",
"path": "./other-repo"
@@ -3436,7 +3437,7 @@
"checkouts": [
{
"name": "local-docs",
- "docs": ["docs/**/*.md"]
+ "paths": ["docs/**/*.md"]
}
],
"searches": [
@@ -3454,7 +3455,7 @@
"checkouts": [
{
"name": "docs",
- "docs": ["docs/**/*.md"]
+ "paths": ["docs/**/*.md"]
}
],
"cache-key": "qmd-index-${{ hashFiles('docs/**') }}"
@@ -9294,14 +9295,13 @@
"qmdCollection": {
"type": "object",
"description": "A named documentation collection for the qmd tool, built from a checked-out repository. Each collection can optionally target a different repository via its own checkout configuration.",
- "required": ["docs"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"description": "Collection identifier used in the qmd index. Defaults to 'docs' for single-collection configs or 'docs-' for multiple collections."
},
- "docs": {
+ "paths": {
"type": "array",
"description": "List of glob patterns for documentation files to include in this collection.",
"items": {
@@ -9310,6 +9310,19 @@
"minItems": 1,
"examples": [["docs/**/*.md", ".github/**/*.md"]]
},
+ "docs": {
+ "type": "array",
+ "description": "Deprecated: use 'paths' instead. List of glob patterns for documentation files to include in this collection.",
+ "items": {
+ "type": "string"
+ },
+ "minItems": 1,
+ "examples": [["docs/**/*.md", ".github/**/*.md"]]
+ },
+ "context": {
+ "type": "string",
+ "description": "Optional context injected into the qmd collection, providing the agent with additional hints about the content (e.g. 'GitHub Actions documentation')."
+ },
"checkout": {
"$ref": "#/$defs/checkoutConfig",
"description": "Optional checkout configuration for this collection. When set, the specified repository is checked out and its files are indexed. Defaults to the current repository if not set."
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 4e256eb49b9..b834fb6de7c 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -34,7 +34,7 @@
// qmd:
// checkouts:
// - name: docs
-// docs:
+// paths:
// - docs/**/*.md
// searches:
// - query: "repo:owner/repo language:Markdown path:docs/"
@@ -59,6 +59,7 @@ package workflow
import (
"fmt"
+ "strconv"
"strings"
"github.com/github/gh-aw/pkg/constants"
@@ -137,7 +138,8 @@ func generateQmdCacheSaveStep(cacheKey string) string {
// with its working directory resolved.
type resolvedQmdCollection struct {
name string
- docs []string
+ paths []string
+ context string
workdir string // absolute path within the runner (e.g. ${GITHUB_WORKSPACE} or /tmp/gh-aw/qmd-checkout-)
}
@@ -165,7 +167,8 @@ func resolveQmdCheckouts(qmdConfig *QmdToolConfig) []resolvedQmdCollection {
}
resolved = append(resolved, resolvedQmdCollection{
name: name,
- docs: col.Docs,
+ paths: col.Paths,
+ context: col.Context,
workdir: workdir,
})
}
@@ -181,7 +184,7 @@ func resolveQmdCheckouts(qmdConfig *QmdToolConfig) []resolvedQmdCollection {
if len(docs) > 0 {
return []resolvedQmdCollection{{
name: "docs",
- docs: docs,
+ paths: docs,
workdir: "${GITHUB_WORKSPACE}",
}}
}
@@ -367,21 +370,31 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
// Register each checkout-based collection.
// The workdir is double-quoted to preserve ${GITHUB_WORKSPACE} variable expansion.
- // User-provided names and globs are POSIX single-quoted to prevent shell injection.
+ // User-provided names, globs, and context are POSIX single-quoted to prevent shell injection.
checkouts := resolveQmdCheckouts(qmdConfig)
for _, col := range checkouts {
var globArg string
- if len(col.docs) > 0 {
- globArg = shellSingleQuote(strings.Join(col.docs, ","))
+ if len(col.paths) > 0 {
+ globArg = shellSingleQuote(strings.Join(col.paths, ","))
} else {
globArg = "'**/*.md'"
}
- steps = append(steps, fmt.Sprintf(
- " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"%s\" --name %s --glob %s\n",
- col.workdir,
- shellSingleQuote(col.name),
- globArg,
- ))
+ if col.context != "" {
+ steps = append(steps, fmt.Sprintf(
+ " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"%s\" --name %s --glob %s --context %s\n",
+ col.workdir,
+ shellSingleQuote(col.name),
+ globArg,
+ shellSingleQuote(col.context),
+ ))
+ } else {
+ steps = append(steps, fmt.Sprintf(
+ " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"%s\" --name %s --glob %s\n",
+ col.workdir,
+ shellSingleQuote(col.name),
+ globArg,
+ ))
+ }
}
// Emit a step per GitHub search entry
@@ -393,6 +406,9 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
if qmdConfig.CacheKey != "" {
steps = append(steps, generateQmdCacheSaveStep(qmdConfig.CacheKey))
}
+
+ // Write a summary of all indexed collections to the step summary
+ steps = append(steps, generateQmdSummaryStep(qmdConfig))
}
// Upload qmd index as a separate artifact for the agent job
@@ -409,6 +425,80 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
return steps
}
+// generateQmdSummaryStep generates a step that writes a Markdown summary of the qmd
+// documentation collections to $GITHUB_STEP_SUMMARY so reviewers can see what was indexed.
+// The table lists each checkout collection (name, paths, context) and each search entry (query).
+func generateQmdSummaryStep(qmdConfig *QmdToolConfig) string {
+ var sb strings.Builder
+ sb.WriteString(" - name: Summarize qmd index\n")
+ sb.WriteString(" if: always()\n")
+ sb.WriteString(" run: |\n")
+ sb.WriteString(" {\n")
+ sb.WriteString(" echo '## qmd documentation index'\n")
+ sb.WriteString(" echo ''\n")
+
+ // Checkout-based collections
+ checkouts := resolveQmdCheckouts(qmdConfig)
+ if len(checkouts) > 0 {
+ sb.WriteString(" echo '### Collections'\n")
+ sb.WriteString(" echo ''\n")
+ sb.WriteString(" echo '| Name | Paths | Context |'\n")
+ sb.WriteString(" echo '| --- | --- | --- |'\n")
+ for _, col := range checkouts {
+ pathsStr := strings.Join(col.paths, ", ")
+ if pathsStr == "" {
+ pathsStr = "**/*.md"
+ }
+ contextStr := col.context
+ if contextStr == "" {
+ contextStr = "-"
+ }
+ fmt.Fprintf(&sb, " echo '| %s | %s | %s |'\n",
+ shellSingleQuoteInRun(col.name),
+ shellSingleQuoteInRun(pathsStr),
+ shellSingleQuoteInRun(contextStr),
+ )
+ }
+ sb.WriteString(" echo ''\n")
+ }
+
+ // Search entries
+ if len(qmdConfig.Searches) > 0 {
+ sb.WriteString(" echo '### Searches'\n")
+ sb.WriteString(" echo ''\n")
+ sb.WriteString(" echo '| Query | Min | Max |'\n")
+ sb.WriteString(" echo '| --- | --- | --- |'\n")
+ for _, s := range qmdConfig.Searches {
+ minStr := "-"
+ if s.Min > 0 {
+ minStr = strconv.Itoa(s.Min)
+ }
+ maxStr := "30"
+ if s.Max > 0 {
+ maxStr = strconv.Itoa(s.Max)
+ }
+ fmt.Fprintf(&sb, " echo '| %s | %s | %s |'\n",
+ shellSingleQuoteInRun(s.Query),
+ minStr,
+ maxStr,
+ )
+ }
+ sb.WriteString(" echo ''\n")
+ }
+
+ sb.WriteString(" } >> $GITHUB_STEP_SUMMARY\n")
+ return sb.String()
+}
+
+// shellSingleQuoteInRun escapes a string for safe embedding inside an already-single-quoted
+// shell echo argument used in run: blocks. Pipes (|) are escaped to prevent Markdown table
+// column breaks and single quotes are neutralized via the '"'"' idiom.
+func shellSingleQuoteInRun(s string) string {
+ s = strings.ReplaceAll(s, "|", "\\|")
+ s = strings.ReplaceAll(s, "'", `'"'"'`)
+ return s
+}
+
// generateQmdDownloadStep generates the agent job step that downloads the qmd-index artifact.
// Returns the steps as a YAML string slice ready to be appended to the agent job steps.
func generateQmdDownloadStep(data *WorkflowData) string {
diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go
index 5af74428044..a284725614c 100644
--- a/pkg/workflow/tools_parser.go
+++ b/pkg/workflow/tools_parser.go
@@ -451,19 +451,30 @@ func parseQmdDocCollection(m map[string]any, index int) *QmdDocCollection {
col.Name = fmt.Sprintf("docs-%d", index)
}
- if docsValue, ok := m["docs"]; ok {
- if arr, ok := docsValue.([]any); ok {
- col.Docs = make([]string, 0, len(arr))
+ // Accept "paths" (new canonical key) and "docs" (legacy key, backward compat)
+ var pathsValue any
+ if v, ok := m["paths"]; ok {
+ pathsValue = v
+ } else if v, ok := m["docs"]; ok {
+ pathsValue = v
+ }
+ if pathsValue != nil {
+ if arr, ok := pathsValue.([]any); ok {
+ col.Paths = make([]string, 0, len(arr))
for _, item := range arr {
if str, ok := item.(string); ok {
- col.Docs = append(col.Docs, str)
+ col.Paths = append(col.Paths, str)
}
}
- } else if arr, ok := docsValue.([]string); ok {
- col.Docs = arr
+ } else if arr, ok := pathsValue.([]string); ok {
+ col.Paths = arr
}
}
+ if context, ok := m["context"].(string); ok {
+ col.Context = context
+ }
+
if checkoutValue, ok := m["checkout"]; ok {
if checkoutMap, ok := checkoutValue.(map[string]any); ok {
if cfg, err := checkoutConfigFromMap(checkoutMap); err == nil {
diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go
index a3657cd6a2c..e01e74af69c 100644
--- a/pkg/workflow/tools_types.go
+++ b/pkg/workflow/tools_types.go
@@ -319,9 +319,14 @@ type QmdDocCollection struct {
// Defaults to "docs" for single-collection configs or "docs-" for multiple collections.
Name string `yaml:"name,omitempty"`
- // Docs is the list of glob patterns for files to include in this collection.
+ // Paths is the list of glob patterns for files to include in this collection.
// Example: ["docs/**/*.md", ".github/**/*.md"]
- Docs []string `yaml:"docs"`
+ // The legacy key "docs" is still accepted for backward compatibility.
+ Paths []string `yaml:"paths"`
+
+ // Context is optional extra context injected into the qmd collection,
+ // providing the agent with additional hints about the content (e.g. "GitHub Actions documentation").
+ Context string `yaml:"context,omitempty"`
// Checkout configures which repository to check out for this collection.
// Uses the same syntax as the top-level checkout configuration.
From bae91d17ccba79af17f525144c3fa18bcc202475 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 02:06:36 +0000
Subject: [PATCH 10/49] Remove all legacy backward-compat features from qmd
(docs shorthand, collections key, docs alias)
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d0e81cd9-a648-405d-9fae-9e37430b0a78
---
docs/src/content/docs/reference/qmd.md | 27 +++----
pkg/parser/schemas/main_workflow_schema.json | 32 +--------
pkg/workflow/compiler_activation_job.go | 2 +-
pkg/workflow/qmd.go | 75 ++++++++------------
pkg/workflow/tools_parser.go | 71 +++---------------
pkg/workflow/tools_types.go | 15 +---
6 files changed, 51 insertions(+), 171 deletions(-)
diff --git a/docs/src/content/docs/reference/qmd.md b/docs/src/content/docs/reference/qmd.md
index 74fae882ea4..c0e83257565 100644
--- a/docs/src/content/docs/reference/qmd.md
+++ b/docs/src/content/docs/reference/qmd.md
@@ -26,9 +26,11 @@ The embedding models used to build and query the index are automatically cached
---
tools:
qmd:
- docs:
- - docs/**/*.md
- - .github/**/*.md
+ checkouts:
+ - name: docs
+ paths:
+ - docs/**/*.md
+ - .github/**/*.md
---
```
@@ -36,18 +38,6 @@ This indexes all markdown files under `docs/` and `.github/` in the current repo
## Configuration
-### Simple form
-
-Index files from the current repository using glob patterns:
-
-```yaml wrap
-tools:
- qmd:
- docs:
- - docs/**/*.md
- - "**/*.mdx"
-```
-
### Checkouts form
Index files from one or more named collections, each with an optional repository checkout:
@@ -117,7 +107,7 @@ tools:
#### Read-only mode
-When `cache-key` is set without any indexing sources (`checkouts`, `searches`, or `docs`), the tool operates in **read-only mode**: the activation job restores the index from cache (failing silently if the cache does not exist yet) and skips all Node.js, npm, and qmd build steps entirely. This is useful for maintaining a shared, pre-built documentation database:
+When `cache-key` is set without any indexing sources (`checkouts` or `searches`), the tool operates in **read-only mode**: the activation job restores the index from cache (failing silently if the cache does not exist yet) and skips all Node.js, npm, and qmd build steps entirely. This is useful for maintaining a shared, pre-built documentation database:
```yaml wrap
tools:
@@ -155,7 +145,6 @@ tools:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
-| `docs` | `string[]` | No | Glob patterns for files in the current repository. Shorthand for a single default collection; ignored when `checkouts` is set. |
| `checkouts` | `QmdDocCollection[]` | No | Named collections, each with optional per-collection checkout. |
| `searches` | `QmdSearchEntry[]` | No | GitHub code search queries whose results are downloaded and indexed. |
| `cache-key` | `string` | No | GitHub Actions cache key for persisting the index across runs. When set without sources, enables read-only mode. |
@@ -164,8 +153,8 @@ tools:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
-| `name` | `string` | No | Collection identifier (defaults to `"docs"`). |
-| `paths` | `string[]` | No | Glob patterns for files to include (defaults to `**/*.md`). The legacy key `docs` is also accepted. |
+| `name` | `string` | No | Collection identifier (defaults to `"docs-"`). |
+| `paths` | `string[]` | No | Glob patterns for files to include (defaults to `**/*.md`). |
| `context` | `string` | No | Optional context hint for the agent about this collection's content (e.g. `"GitHub Actions documentation"`). |
| `checkout` | `CheckoutConfig` | No | Repository checkout options — same syntax as the top-level [`checkout:`](/gh-aw/reference/frontmatter/#checkout) field. Defaults to the current repository. |
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 6871ce90f65..2851f248b5f 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -3379,33 +3379,14 @@
},
"minItems": 1
},
- "docs": {
- "type": "array",
- "description": "Legacy shorthand: list of glob patterns for a single collection targeting the current repository. Use checkouts for more control.",
- "items": {
- "type": "string"
- },
- "examples": [["docs/**/*.md", ".github/**/*.md"]]
- },
- "collections": {
- "type": "array",
- "description": "Legacy: use checkouts instead. List of named documentation collections (backward compatible).",
- "items": {
- "$ref": "#/$defs/qmdCollection"
- },
- "minItems": 1
- },
"cache-key": {
"type": "string",
- "description": "GitHub Actions cache key used to persist the qmd index across workflow runs. When set without any indexing sources (checkouts/searches/docs), qmd operates in read-only mode: the index is restored from cache and all indexing steps are skipped.",
+ "description": "GitHub Actions cache key used to persist the qmd index across workflow runs. When set without any indexing sources (checkouts/searches), qmd operates in read-only mode: the index is restored from cache and all indexing steps are skipped.",
"examples": ["qmd-index-${{ hashFiles('docs/**') }}", "qmd-index-v1"]
}
},
"additionalProperties": false,
"examples": [
- {
- "docs": ["docs/**/*.md", ".github/**/*.md"]
- },
{
"checkouts": [
{
@@ -9299,7 +9280,7 @@
"properties": {
"name": {
"type": "string",
- "description": "Collection identifier used in the qmd index. Defaults to 'docs' for single-collection configs or 'docs-' for multiple collections."
+ "description": "Collection identifier used in the qmd index. Defaults to 'docs-' for multiple collections."
},
"paths": {
"type": "array",
@@ -9310,15 +9291,6 @@
"minItems": 1,
"examples": [["docs/**/*.md", ".github/**/*.md"]]
},
- "docs": {
- "type": "array",
- "description": "Deprecated: use 'paths' instead. List of glob patterns for documentation files to include in this collection.",
- "items": {
- "type": "string"
- },
- "minItems": 1,
- "examples": [["docs/**/*.md", ".github/**/*.md"]]
- },
"context": {
"type": "string",
"description": "Optional context injected into the qmd collection, providing the agent with additional hints about the content (e.g. 'GitHub Actions documentation')."
diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go
index 230e34fa1d2..7642f8fe828 100644
--- a/pkg/workflow/compiler_activation_job.go
+++ b/pkg/workflow/compiler_activation_job.go
@@ -489,7 +489,7 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
// The index is built here in the activation job (which has contents:read) so the agent job
// does not need contents:read permission to search documentation.
if data.QmdConfig != nil {
- compilerActivationJobLog.Printf("Adding qmd index build steps: docs=%v", data.QmdConfig.Docs)
+ compilerActivationJobLog.Printf("Adding qmd index build steps: checkouts=%d searches=%d", len(data.QmdConfig.Checkouts), len(data.QmdConfig.Searches))
qmdSteps := generateQmdIndexSteps(data.QmdConfig, data)
steps = append(steps, qmdSteps...)
}
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index b834fb6de7c..07a4874b079 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -86,10 +86,10 @@ func hasQmdTool(parsedTools *Tools) bool {
}
// qmdHasSources reports whether the qmd config has any indexing sources
-// (checkouts, searches, or legacy docs). When false and a cache-key is set,
+// (checkouts or searches). When false and a cache-key is set,
// qmd operates in read-only mode: the index is restored from cache only.
func qmdHasSources(qmdConfig *QmdToolConfig) bool {
- return len(qmdConfig.Checkouts) > 0 || len(qmdConfig.Searches) > 0 || len(qmdConfig.Docs) > 0
+ return len(qmdConfig.Checkouts) > 0 || len(qmdConfig.Searches) > 0
}
// generateQmdModelsCacheStep generates a step that caches the qmd embedding models directory
@@ -143,52 +143,37 @@ type resolvedQmdCollection struct {
workdir string // absolute path within the runner (e.g. ${GITHUB_WORKSPACE} or /tmp/gh-aw/qmd-checkout-)
}
-// resolveQmdCheckouts converts the checkouts (or legacy docs) portion of a QmdToolConfig
+// resolveQmdCheckouts converts the checkouts portion of a QmdToolConfig
// into a list of resolvedQmdCollections.
func resolveQmdCheckouts(qmdConfig *QmdToolConfig) []resolvedQmdCollection {
- // Structured form: explicit checkouts list
- if len(qmdConfig.Checkouts) > 0 {
- resolved := make([]resolvedQmdCollection, 0, len(qmdConfig.Checkouts))
- for _, col := range qmdConfig.Checkouts {
- name := col.Name
- if name == "" {
- name = "docs"
- }
- workdir := "${GITHUB_WORKSPACE}"
- if col.Checkout != nil {
- if col.Checkout.Path != "" {
- // Checkout path is relative to GITHUB_WORKSPACE; strip leading "./" for cleanliness
- checkoutPath := strings.TrimPrefix(col.Checkout.Path, "./")
- workdir = "${GITHUB_WORKSPACE}/" + checkoutPath
- } else {
- // No explicit path → use an isolated temp directory
- workdir = "/tmp/gh-aw/qmd-checkout-" + name
- }
+ if len(qmdConfig.Checkouts) == 0 {
+ return nil
+ }
+ resolved := make([]resolvedQmdCollection, 0, len(qmdConfig.Checkouts))
+ for _, col := range qmdConfig.Checkouts {
+ name := col.Name
+ if name == "" {
+ name = "docs"
+ }
+ workdir := "${GITHUB_WORKSPACE}"
+ if col.Checkout != nil {
+ if col.Checkout.Path != "" {
+ // Checkout path is relative to GITHUB_WORKSPACE; strip leading "./" for cleanliness
+ checkoutPath := strings.TrimPrefix(col.Checkout.Path, "./")
+ workdir = "${GITHUB_WORKSPACE}/" + checkoutPath
+ } else {
+ // No explicit path → use an isolated temp directory
+ workdir = "/tmp/gh-aw/qmd-checkout-" + name
}
- resolved = append(resolved, resolvedQmdCollection{
- name: name,
- paths: col.Paths,
- context: col.Context,
- workdir: workdir,
- })
}
- return resolved
- }
-
- // Legacy form: docs shorthand → single default collection
- docs := qmdConfig.Docs
- if len(docs) == 0 && len(qmdConfig.Searches) == 0 {
- // No explicit docs and no searches → default to all markdown
- docs = []string{"**/*.md"}
- }
- if len(docs) > 0 {
- return []resolvedQmdCollection{{
- name: "docs",
- paths: docs,
- workdir: "${GITHUB_WORKSPACE}",
- }}
+ resolved = append(resolved, resolvedQmdCollection{
+ name: name,
+ paths: col.Paths,
+ context: col.Context,
+ workdir: workdir,
+ })
}
- return nil
+ return resolved
}
// generateQmdCollectionCheckoutStep generates a checkout step YAML string for a qmd
@@ -310,8 +295,8 @@ func generateQmdSearchStep(entry *QmdSearchEntry, index int) string {
func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []string {
hasSources := qmdHasSources(qmdConfig)
isCacheOnlyMode := qmdConfig.CacheKey != "" && !hasSources
- qmdLog.Printf("Generating qmd index steps: docs=%v checkouts=%d searches=%d cacheKey=%q cacheOnly=%v",
- qmdConfig.Docs, len(qmdConfig.Checkouts), len(qmdConfig.Searches), qmdConfig.CacheKey, isCacheOnlyMode)
+ qmdLog.Printf("Generating qmd index steps: checkouts=%d searches=%d cacheKey=%q cacheOnly=%v",
+ len(qmdConfig.Checkouts), len(qmdConfig.Searches), qmdConfig.CacheKey, isCacheOnlyMode)
version := string(constants.DefaultQmdVersion)
var steps []string
diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go
index a284725614c..5662f5b486f 100644
--- a/pkg/workflow/tools_parser.go
+++ b/pkg/workflow/tools_parser.go
@@ -334,18 +334,11 @@ func parsePlaywrightTool(val any) *PlaywrightToolConfig {
}
// parseQmdTool converts raw qmd tool configuration to QmdToolConfig.
-// Supported forms (from most to least preferred):
+// Supported fields:
//
-// 1. New structured form:
-// checkouts: list of named collections (with optional checkout per entry)
-// searches: list of GitHub search queries
-// cache-key: optional GitHub Actions cache key
-//
-// 2. Legacy extended form (backward-compatible):
-// collections: list of named collections (treated as checkouts)
-//
-// 3. Legacy simple form (backward-compatible):
-// docs: list of glob patterns (single collection, current repository)
+// - checkouts: list of named collections (with optional checkout per entry)
+// - searches: list of GitHub search queries
+// - cache-key: optional GitHub Actions cache key
func parseQmdTool(val any) *QmdToolConfig {
if val == nil {
toolsParserLog.Print("qmd tool enabled with empty configuration")
@@ -355,13 +348,13 @@ func parseQmdTool(val any) *QmdToolConfig {
if configMap, ok := val.(map[string]any); ok {
config := &QmdToolConfig{}
- // Handle cache-key field (applies to all forms)
+ // Handle cache-key field
if cacheKey, ok := configMap["cache-key"].(string); ok && cacheKey != "" {
config.CacheKey = cacheKey
toolsParserLog.Printf("qmd tool cache-key: %s", cacheKey)
}
- // Handle checkouts field (new structured form)
+ // Handle checkouts field
if checkoutsValue, ok := configMap["checkouts"]; ok {
if arr, ok := checkoutsValue.([]any); ok {
config.Checkouts = make([]*QmdDocCollection, 0, len(arr))
@@ -377,7 +370,7 @@ func parseQmdTool(val any) *QmdToolConfig {
}
}
- // Handle searches field (new structured form)
+ // Handle searches field
if searchesValue, ok := configMap["searches"]; ok {
if arr, ok := searchesValue.([]any); ok {
config.Searches = make([]*QmdSearchEntry, 0, len(arr))
@@ -393,47 +386,6 @@ func parseQmdTool(val any) *QmdToolConfig {
}
}
- // If either new key was found, return now (new form takes precedence)
- if len(config.Checkouts) > 0 || len(config.Searches) > 0 {
- return config
- }
-
- // Return early if cache-key-only (read-only mode — no indexing sources)
- if config.CacheKey != "" {
- return config
- }
-
- // Legacy: handle collections field (treated as checkouts for backward compat)
- if collectionsValue, ok := configMap["collections"]; ok {
- if arr, ok := collectionsValue.([]any); ok {
- config.Checkouts = make([]*QmdDocCollection, 0, len(arr))
- for i, item := range arr {
- itemMap, ok := item.(map[string]any)
- if !ok {
- continue
- }
- col := parseQmdDocCollection(itemMap, i)
- config.Checkouts = append(config.Checkouts, col)
- }
- toolsParserLog.Printf("qmd tool parsed %d legacy collections (mapped to checkouts)", len(config.Checkouts))
- return config
- }
- }
-
- // Legacy: handle docs field - simple glob list (single collection, current repo)
- if docsValue, ok := configMap["docs"]; ok {
- if arr, ok := docsValue.([]any); ok {
- config.Docs = make([]string, 0, len(arr))
- for _, item := range arr {
- if str, ok := item.(string); ok {
- config.Docs = append(config.Docs, str)
- }
- }
- } else if arr, ok := docsValue.([]string); ok {
- config.Docs = arr
- }
- }
-
return config
}
@@ -451,14 +403,7 @@ func parseQmdDocCollection(m map[string]any, index int) *QmdDocCollection {
col.Name = fmt.Sprintf("docs-%d", index)
}
- // Accept "paths" (new canonical key) and "docs" (legacy key, backward compat)
- var pathsValue any
- if v, ok := m["paths"]; ok {
- pathsValue = v
- } else if v, ok := m["docs"]; ok {
- pathsValue = v
- }
- if pathsValue != nil {
+ if pathsValue, ok := m["paths"]; ok {
if arr, ok := pathsValue.([]any); ok {
col.Paths = make([]string, 0, len(arr))
for _, item := range arr {
diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go
index e01e74af69c..ff8dc602dcc 100644
--- a/pkg/workflow/tools_types.go
+++ b/pkg/workflow/tools_types.go
@@ -321,7 +321,6 @@ type QmdDocCollection struct {
// Paths is the list of glob patterns for files to include in this collection.
// Example: ["docs/**/*.md", ".github/**/*.md"]
- // The legacy key "docs" is still accepted for backward compatibility.
Paths []string `yaml:"paths"`
// Context is optional extra context injected into the qmd collection,
@@ -370,21 +369,11 @@ type QmdSearchEntry struct {
// 2. searches – GitHub search queries whose results are downloaded as files
//
// Optionally, the index can be cached in GitHub Actions cache using the cache-key field.
-// When cache-key is set without any sources (checkouts/searches/docs), qmd operates in
+// When cache-key is set without any sources (checkouts/searches), qmd operates in
// read-only mode: it restores the index from cache and skips all indexing steps.
-//
-// Legacy shorthand: docs and collections fields are still accepted for backward
-// compatibility and are treated as-if under checkouts.
type QmdToolConfig struct {
- // Docs is the legacy list of glob patterns for files to include in the search index.
- // Shorthand for a single default collection targeting the current repository.
- // When Checkouts is also set, Docs is ignored.
- // Example: ["docs/**/*.md", ".github/**/*.md"]
- Docs []string `yaml:"docs,omitempty"`
-
// Checkouts is the list of named documentation collections.
// Each collection can specify its own checkout to target a different repository.
- // When set, Docs is ignored.
Checkouts []*QmdDocCollection `yaml:"checkouts,omitempty"`
// Searches is the list of GitHub search queries whose results are downloaded
@@ -393,7 +382,7 @@ type QmdToolConfig struct {
// CacheKey is an optional GitHub Actions cache key used to persist the qmd index
// across workflow runs. When set:
- // - If sources (checkouts/searches/docs) are also configured: the index is built
+ // - If sources (checkouts/searches) are also configured: the index is built
// normally and then saved to the cache. On subsequent runs, the cached index is
// restored and the build steps are skipped if the cache hit is exact.
// - If no sources are configured (read-only mode): the index is restored directly
From a7c99a59ba300cec713d8fe47136867b80123b42 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 03:09:12 +0000
Subject: [PATCH 11/49] Use actions/cache/restore (read-only) for qmd models
cache in agent job
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/a1f6d09e-87c0-4b1d-8266-486db8980cac
---
pkg/workflow/compiler_yaml_main_job.go | 4 ++--
pkg/workflow/qmd.go | 18 ++++++++++++++++--
2 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index 17ef4e69f54..81193643ab3 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -284,8 +284,8 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
if data.QmdConfig != nil {
compilerYamlLog.Print("Adding qmd index download step")
yaml.WriteString(generateQmdDownloadStep(data))
- compilerYamlLog.Print("Adding qmd models cache step")
- yaml.WriteString(generateQmdModelsCacheStep())
+ compilerYamlLog.Print("Adding qmd models cache restore step (read-only)")
+ yaml.WriteString(generateQmdModelsCacheRestoreStep())
}
// GH_AW_SAFE_OUTPUTS is now set at job level, no setup step needed
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 07a4874b079..409592a8e38 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -95,8 +95,8 @@ func qmdHasSources(qmdConfig *QmdToolConfig) bool {
// generateQmdModelsCacheStep generates a step that caches the qmd embedding models directory
// (~/.cache/qmd/models/). It uses the combined actions/cache action (restore + post-save),
// keyed by OS so that the cached models are compatible with the runner architecture.
-// This step should be emitted in both the activation job (before index building) and the
-// agent job (before the qmd MCP server starts) to avoid re-downloading models on each run.
+// This step should be emitted in the activation job (before index building) to populate
+// the cache. For the agent job, use generateQmdModelsCacheRestoreStep instead.
func generateQmdModelsCacheStep() string {
var sb strings.Builder
sb.WriteString(" - name: Cache qmd models\n")
@@ -107,6 +107,20 @@ func generateQmdModelsCacheStep() string {
return sb.String()
}
+// generateQmdModelsCacheRestoreStep generates a read-only step that restores the qmd embedding
+// models directory (~/.cache/qmd/models/) from GitHub Actions cache. It uses
+// actions/cache/restore (restore-only, no post-save) so the agent job never writes to the
+// shared cache — that is the activation job's responsibility.
+func generateQmdModelsCacheRestoreStep() string {
+ var sb strings.Builder
+ sb.WriteString(" - name: Restore qmd models cache\n")
+ fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/restore"))
+ sb.WriteString(" with:\n")
+ sb.WriteString(" path: ~/.cache/qmd/models/\n")
+ sb.WriteString(" key: qmd-models-${{ runner.os }}\n")
+ return sb.String()
+}
+
// generateQmdCacheRestoreStep generates an activation-job step that restores the qmd index
// from GitHub Actions cache. The step ID is "qmd-cache-restore" so that subsequent steps
// can check cache-hit via steps.qmd-cache-restore.outputs.cache-hit.
From a514cd605f527009654678e4bd2a390ae7e69db9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 03:20:13 +0000
Subject: [PATCH 12/49] Add named/issue-search support to QmdSearchEntry; add
qmd config to smoke-codex
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/1ea4ff00-5c83-46de-b990-7f65ec8fab63
---
.github/workflows/smoke-codex.lock.yml | 87 +++++++++++++++++++-
.github/workflows/smoke-codex.md | 12 +++
pkg/parser/schemas/main_workflow_schema.json | 17 +++-
pkg/workflow/qmd.go | 71 +++++++++++++++-
pkg/workflow/tools_parser.go | 6 ++
pkg/workflow/tools_types.go | 34 +++++---
6 files changed, 208 insertions(+), 19 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index e18100f24aa..599d420d0d9 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -27,7 +27,7 @@
# - shared/gh.md
# - shared/reporting.md
#
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"0f4668b9ab1badf192f489c4a66a16cdaa218a0f31d7ad325ec26159e0d7e4d8","strict":true,"agent_id":"codex"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"338f7f38ce542d6b99d8013d5aff53a4148ff2d6cb6cb63423c91b6def49b84e","strict":true,"agent_id":"codex"}
name: "Smoke Codex"
"on":
@@ -190,6 +190,7 @@ jobs:
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/playwright_prompt.md"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/qmd_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_EOF'
@@ -308,6 +309,58 @@ jobs:
env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh
+ - name: Cache qmd models
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/qmd/models/
+ key: qmd-models-${{ runner.os }}
+ - name: Setup Node.js for qmd
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: "24"
+ - name: Install qmd
+ run: |
+ npm install -g @tobilu/qmd@0.0.16
+ - name: Build qmd index
+ run: |
+ set -e
+ mkdir -p /tmp/gh-aw/qmd-index
+ QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add "${GITHUB_WORKSPACE}" --name 'docs' --glob 'docs/src/**/*.md,docs/src/**/*.mdx' --context 'gh-aw project documentation'
+ - name: Fetch GitHub issues for qmd collection "issues"
+ run: |
+ set -e
+ mkdir -p /tmp/gh-aw/qmd-search-0
+ # Fetch open issues and save each as a markdown file
+ GH_TOKEN=${{ secrets.GITHUB_TOKEN }} gh issue list --repo '${{ github.repository }}' --state open --limit 500 --json number,title,body | \
+ jq -r '.[] | "## " + (.number | tostring) + ": " + .title + "\n\n" + (.body // "") | @text' | \
+ awk 'BEGIN{n=0} /^## [0-9]+:/{n++; file="/tmp/gh-aw/qmd-search-0/issue-" n ".md"} {print > file}'
+ QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add '/tmp/gh-aw/qmd-search-0' --name 'issues' --glob '**/*'
+ - name: Summarize qmd index
+ if: always()
+ run: |
+ {
+ echo '## qmd documentation index'
+ echo ''
+ echo '### Collections'
+ echo ''
+ echo '| Name | Paths | Context |'
+ echo '| --- | --- | --- |'
+ echo '| docs | docs/src/**/*.md, docs/src/**/*.mdx | gh-aw project documentation |'
+ echo ''
+ echo '### Searches'
+ echo ''
+ echo '| Query | Min | Max |'
+ echo '| --- | --- | --- |'
+ echo '| | - | 500 |'
+ echo ''
+ } >> $GITHUB_STEP_SUMMARY
+ - name: Upload qmd index artifact
+ if: success()
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ with:
+ name: qmd-index
+ path: /tmp/gh-aw/qmd-index/
+ retention-days: 1
- name: Upload activation artifact
if: success()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
@@ -420,6 +473,16 @@ jobs:
run: npm install -g @openai/codex@latest
- name: Install AWF binary
run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5
+ - name: Download qmd index artifact
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: qmd-index
+ path: /tmp/gh-aw/qmd-index/
+ - name: Restore qmd models cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/qmd/models/
+ key: qmd-models-${{ runner.os }}
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -892,6 +955,16 @@ jobs:
[mcp_servers.playwright."guard-policies".write-sink]
accept = ["*"]
+ [mcp_servers.qmd]
+ container = "node:lts-alpine"
+ entrypoint = "npx"
+ entrypointArgs = [
+ "@tobilu/qmd@0.0.16",
+ "serve-mcp",
+ ]
+ mounts = ["/tmp/gh-aw/qmd-index:/tmp/gh-aw/qmd-index:ro"]
+ env_vars = ["QMD_CACHE_DIR"]
+
[mcp_servers.safeoutputs]
type = "http"
url = "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT"
@@ -991,6 +1064,18 @@ jobs:
}
}
},
+ "qmd": {
+ "container": "node:lts-alpine",
+ "entrypoint": "npx",
+ "entrypointArgs": [
+ "@tobilu/qmd@0.0.16",
+ "serve-mcp"
+ ],
+ "mounts": ["/tmp/gh-aw/qmd-index:/tmp/gh-aw/qmd-index:ro"],
+ "env": {
+ "QMD_CACHE_DIR": "/tmp/gh-aw/qmd-index"
+ }
+ },
"safeoutputs": {
"type": "http",
"url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
diff --git a/.github/workflows/smoke-codex.md b/.github/workflows/smoke-codex.md
index 87b345ae83e..6f0d90d6b7c 100644
--- a/.github/workflows/smoke-codex.md
+++ b/.github/workflows/smoke-codex.md
@@ -34,6 +34,18 @@ tools:
languages:
go: {}
web-fetch:
+ qmd:
+ checkouts:
+ - name: docs
+ paths:
+ - docs/src/**/*.md
+ - docs/src/**/*.mdx
+ context: "gh-aw project documentation"
+ searches:
+ - name: issues
+ type: issues
+ max: 500
+ github-token: ${{ secrets.GITHUB_TOKEN }}
runtimes:
go:
version: "1.25"
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 2851f248b5f..d3adeb50668 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -9303,14 +9303,23 @@
},
"qmdSearchEntry": {
"type": "object",
- "description": "A GitHub search query entry for the qmd tool. The search is executed during the activation job and the matching files are downloaded and added to the qmd index.",
- "required": ["query"],
+ "description": "A GitHub search entry for the qmd tool. Supports code search (type: code, default) and GitHub issue list (type: issues). Results are downloaded and indexed as qmd collections.",
"additionalProperties": false,
"properties": {
+ "name": {
+ "type": "string",
+ "description": "Optional name for the qmd collection. Defaults to \"search-{index}\" when not set."
+ },
+ "type": {
+ "type": "string",
+ "enum": ["code", "issues"],
+ "default": "code",
+ "description": "Search backend type. \"code\" (default) uses gh search code to find repository files. \"issues\" uses gh issue list to fetch open GitHub issues and index them as markdown files."
+ },
"query": {
"type": "string",
- "description": "GitHub code search query string. See https://docs.github.com/en/search-github/searching-on-github/searching-code for syntax.",
- "examples": ["repo:owner/repo language:Markdown path:docs/", "org:myorg language:Markdown"]
+ "description": "For type \"code\": GitHub code search query string. For type \"issues\": repository slug (\"owner/repo\"); defaults to the current repository when empty.",
+ "examples": ["repo:owner/repo language:Markdown path:docs/", "org:myorg language:Markdown", "owner/repo"]
},
"min": {
"type": "integer",
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 409592a8e38..002fe183747 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -238,11 +238,74 @@ func generateQmdCollectionCheckoutStep(col *QmdDocCollection) string {
return sb.String()
}
-// generateQmdSearchStep generates an activation-job step that runs a GitHub search query,
-// downloads the matching files, and adds them as a qmd collection named after the search index.
-// The step uses the gh CLI to execute the search.
+// generateQmdSearchStep generates an activation-job step that runs a GitHub search or issue
+// list, saves the results as individual files, and adds them as a named qmd collection.
+// When entry.Type is "issues", it uses `gh issue list` to fetch open issues from the
+// repository and formats each as a markdown file. Otherwise (default "code" type) it uses
+// `gh search code` to find repository files.
func generateQmdSearchStep(entry *QmdSearchEntry, index int) string {
- collectionName := fmt.Sprintf("search-%d", index)
+ collectionName := entry.Name
+ if collectionName == "" {
+ collectionName = fmt.Sprintf("search-%d", index)
+ }
+
+ if entry.Type == "issues" {
+ return generateQmdIssueListStep(entry, collectionName, index)
+ }
+ return generateQmdCodeSearchStep(entry, collectionName, index)
+}
+
+// generateQmdIssueListStep generates a step that fetches open GitHub issues from a
+// repository using `gh issue list` and saves each issue as a markdown file so they
+// can be indexed by qmd.
+func generateQmdIssueListStep(entry *QmdSearchEntry, collectionName string, index int) string {
+ searchDir := fmt.Sprintf("/tmp/gh-aw/qmd-search-%d", index)
+
+ maxResults := entry.Max
+ if maxResults <= 0 {
+ maxResults = 500
+ }
+
+ repo := entry.Query
+ if repo == "" {
+ repo = "${{ github.repository }}"
+ }
+
+ var tokenEnv string
+ if entry.GitHubToken != "" {
+ tokenEnv = fmt.Sprintf("GH_TOKEN=%s ", entry.GitHubToken)
+ }
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, " - name: Fetch GitHub issues for qmd collection %q\n", collectionName)
+ sb.WriteString(" run: |\n")
+ sb.WriteString(" set -e\n")
+ fmt.Fprintf(&sb, " mkdir -p %s\n", searchDir)
+ sb.WriteString(" # Fetch open issues and save each as a markdown file\n")
+ fmt.Fprintf(&sb, " %sgh issue list --repo %s --state open --limit %d --json number,title,body | \\\n",
+ tokenEnv, shellSingleQuote(repo), maxResults)
+ fmt.Fprintf(&sb, " jq -r '.[] | \"## \" + (.number | tostring) + \": \" + .title + \"\\n\\n\" + (.body // \"\") | @text' | \\\n")
+ fmt.Fprintf(&sb, " awk 'BEGIN{n=0} /^## [0-9]+:/{n++; file=\"%s/issue-\" n \".md\"} {print > file}'\n", searchDir)
+
+ if entry.Min > 0 {
+ fmt.Fprintf(&sb, " count=$(find %s -type f | wc -l)\n", searchDir)
+ fmt.Fprintf(&sb, " if [ \"$count\" -lt %d ]; then\n", entry.Min)
+ fmt.Fprintf(&sb, " echo \"qmd issue list %q returned $count results, minimum is %d\" >&2\n", collectionName, entry.Min)
+ sb.WriteString(" exit 1\n")
+ sb.WriteString(" fi\n")
+ }
+
+ fmt.Fprintf(&sb, " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add %s --name %s --glob %s\n",
+ shellSingleQuote(searchDir),
+ shellSingleQuote(collectionName),
+ "'**/*'",
+ )
+ return sb.String()
+}
+
+// generateQmdCodeSearchStep generates an activation-job step that runs a GitHub code
+// search query, downloads the matching files, and adds them as a named qmd collection.
+func generateQmdCodeSearchStep(entry *QmdSearchEntry, collectionName string, index int) string {
searchDir := fmt.Sprintf("/tmp/gh-aw/qmd-search-%d", index)
maxResults := entry.Max
diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go
index 5662f5b486f..472a154ffa1 100644
--- a/pkg/workflow/tools_parser.go
+++ b/pkg/workflow/tools_parser.go
@@ -437,6 +437,12 @@ func parseQmdDocCollection(m map[string]any, index int) *QmdDocCollection {
func parseQmdSearchEntry(m map[string]any) *QmdSearchEntry {
entry := &QmdSearchEntry{}
+ if n, ok := m["name"].(string); ok {
+ entry.Name = n
+ }
+ if t, ok := m["type"].(string); ok {
+ entry.Type = t
+ }
if q, ok := m["query"].(string); ok {
entry.Query = q
}
diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go
index ff8dc602dcc..14f91724814 100644
--- a/pkg/workflow/tools_types.go
+++ b/pkg/workflow/tools_types.go
@@ -333,27 +333,41 @@ type QmdDocCollection struct {
Checkout *CheckoutConfig `yaml:"checkout,omitempty"`
}
-// QmdSearchEntry represents a single GitHub search query whose results are
+// QmdSearchEntry represents a single GitHub search entry whose results are
// downloaded and added to the qmd index as individual files.
type QmdSearchEntry struct {
- // Query is the GitHub code/content search query string.
- // Example: "repo:owner/repo language:Markdown path:docs/"
- Query string `yaml:"query"`
+ // Name is an optional name for the resulting qmd collection.
+ // When empty, the collection is named "search-{index}".
+ Name string `yaml:"name,omitempty"`
- // Min is the minimum number of search results required. If fewer results
- // are returned the step fails with an error.
+ // Type controls the search backend. Supported values:
+ // "code" (default) – uses `gh search code` to find repository files
+ // "issues" – uses `gh issue list` to fetch open issues from
+ // a repository and save each as a markdown file
+ // When type is "issues", Query is the repository slug ("owner/repo").
+ // If Query is empty for an issue search, ${{ github.repository }} is used.
+ Type string `yaml:"type,omitempty"`
+
+ // Query is the GitHub code search query string (type "code") or the
+ // repository slug "owner/repo" (type "issues").
+ // Example (code): "repo:owner/repo language:Markdown path:docs/"
+ // Example (issues): "owner/repo" (or empty to use current repository)
+ Query string `yaml:"query,omitempty"`
+
+ // Min is the minimum number of results required. If fewer are found
+ // the activation step fails.
Min int `yaml:"min,omitempty"`
- // Max is the maximum number of search results to download.
- // Defaults to 30 when not set.
+ // Max is the maximum number of results to download.
+ // Defaults to 30 (type "code") or 500 (type "issues") when not set.
Max int `yaml:"max,omitempty"`
// GitHubToken overrides the default GITHUB_TOKEN used to authenticate
- // the GitHub search API request.
+ // the GitHub API request.
// Mutually exclusive with GitHubApp.
GitHubToken string `yaml:"github-token,omitempty"`
- // GitHubApp configures GitHub App-based authentication for the search request.
+ // GitHubApp configures GitHub App-based authentication for the API request.
// Mutually exclusive with GitHubToken.
GitHubApp *GitHubAppConfig `yaml:"github-app,omitempty"`
}
From 0214243ccd4346b06dfd7612e7b388ba6b792fea Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 03:44:03 +0000
Subject: [PATCH 13/49] =?UTF-8?q?Fix=20@tobilu/qmd=20npm=20version:=200.0.?=
=?UTF-8?q?16=20=E2=86=92=202.0.1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/65fa2584-3040-4635-8344-d25b3e2cffa7
---
.github/workflows/smoke-codex.lock.yml | 6 +++---
pkg/constants/constants.go | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 599d420d0d9..f6bc490f6f7 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -320,7 +320,7 @@ jobs:
node-version: "24"
- name: Install qmd
run: |
- npm install -g @tobilu/qmd@0.0.16
+ npm install -g @tobilu/qmd@2.0.1
- name: Build qmd index
run: |
set -e
@@ -959,7 +959,7 @@ jobs:
container = "node:lts-alpine"
entrypoint = "npx"
entrypointArgs = [
- "@tobilu/qmd@0.0.16",
+ "@tobilu/qmd@2.0.1",
"serve-mcp",
]
mounts = ["/tmp/gh-aw/qmd-index:/tmp/gh-aw/qmd-index:ro"]
@@ -1068,7 +1068,7 @@ jobs:
"container": "node:lts-alpine",
"entrypoint": "npx",
"entrypointArgs": [
- "@tobilu/qmd@0.0.16",
+ "@tobilu/qmd@2.0.1",
"serve-mcp"
],
"mounts": ["/tmp/gh-aw/qmd-index:/tmp/gh-aw/qmd-index:ro"],
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index faf09e0ef61..7d1fbf17158 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -413,7 +413,7 @@ const DefaultAPMVersion Version = "v0.8.2"
const DefaultPlaywrightMCPVersion Version = "0.0.68"
// DefaultQmdVersion is the default version of the @tobilu/qmd npm package
-const DefaultQmdVersion Version = "0.0.16"
+const DefaultQmdVersion Version = "2.0.1"
// DefaultPlaywrightBrowserVersion is the default version of the Playwright browser Docker image
const DefaultPlaywrightBrowserVersion Version = "v1.58.2"
From 7df829d8091f0714fb5e930ba5981f8c7df7929d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 04:12:56 +0000
Subject: [PATCH 14/49] refactor: replace qmd shell steps with github-script +
qmd JS SDK
Instead of generating many shell run: steps for the qmd activation job,
serialize the config as JSON (QMD_CONFIG_JSON env var) and execute a
single actions/github-script step that calls qmd_index.cjs.
The new qmd_index.cjs file uses the @tobilu/qmd JavaScript SDK to:
- Register checkout-based collections via createStore({ config })
- Fetch GitHub code search / issue list results via Octokit and save as files
- Call store.update() to index all files
- Call store.embed() to generate vector embeddings
- Write a Markdown step summary via core.summary
The @tobilu/qmd SDK is installed locally via npm install --prefix
into the gh-aw actions directory so qmd_index.cjs can require() it.
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4131ae22-2f1c-4bc0-a7f9-7483f102cc35
---
.github/workflows/smoke-codex.lock.yml | 48 +--
actions/setup/js/qmd_index.cjs | 267 +++++++++++++++
pkg/workflow/qmd.go | 434 ++++++++-----------------
3 files changed, 421 insertions(+), 328 deletions(-)
create mode 100644 actions/setup/js/qmd_index.cjs
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index f6bc490f6f7..6d8b19106b2 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -318,42 +318,22 @@ jobs:
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "24"
- - name: Install qmd
+ - name: Install @tobilu/qmd SDK
run: |
- npm install -g @tobilu/qmd@2.0.1
+ npm install --prefix "${{ runner.temp }}/gh-aw/actions" @tobilu/qmd@2.0.1
- name: Build qmd index
- run: |
- set -e
- mkdir -p /tmp/gh-aw/qmd-index
- QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add "${GITHUB_WORKSPACE}" --name 'docs' --glob 'docs/src/**/*.md,docs/src/**/*.mdx' --context 'gh-aw project documentation'
- - name: Fetch GitHub issues for qmd collection "issues"
- run: |
- set -e
- mkdir -p /tmp/gh-aw/qmd-search-0
- # Fetch open issues and save each as a markdown file
- GH_TOKEN=${{ secrets.GITHUB_TOKEN }} gh issue list --repo '${{ github.repository }}' --state open --limit 500 --json number,title,body | \
- jq -r '.[] | "## " + (.number | tostring) + ": " + .title + "\n\n" + (.body // "") | @text' | \
- awk 'BEGIN{n=0} /^## [0-9]+:/{n++; file="/tmp/gh-aw/qmd-search-0/issue-" n ".md"} {print > file}'
- QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add '/tmp/gh-aw/qmd-search-0' --name 'issues' --glob '**/*'
- - name: Summarize qmd index
- if: always()
- run: |
- {
- echo '## qmd documentation index'
- echo ''
- echo '### Collections'
- echo ''
- echo '| Name | Paths | Context |'
- echo '| --- | --- | --- |'
- echo '| docs | docs/src/**/*.md, docs/src/**/*.mdx | gh-aw project documentation |'
- echo ''
- echo '### Searches'
- echo ''
- echo '| Query | Min | Max |'
- echo '| --- | --- | --- |'
- echo '| | - | 500 |'
- echo ''
- } >> $GITHUB_STEP_SUMMARY
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ QMD_CONFIG_JSON: |
+ {"dbPath":"/tmp/gh-aw/qmd-index","checkouts":[{"name":"docs","path":"${GITHUB_WORKSPACE}","patterns":["docs/src/**/*.md","docs/src/**/*.mdx"],"context":"gh-aw project documentation"}],"searches":[{"name":"issues","type":"issues","max":500,"tokenEnvVar":"QMD_SEARCH_TOKEN_0"}]}
+ QMD_SEARCH_TOKEN_0: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ github-token: ${{ github.token }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/qmd_index.cjs');
+ await main();
- name: Upload qmd index artifact
if: success()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
diff --git a/actions/setup/js/qmd_index.cjs b/actions/setup/js/qmd_index.cjs
new file mode 100644
index 00000000000..333efb2e0c9
--- /dev/null
+++ b/actions/setup/js/qmd_index.cjs
@@ -0,0 +1,267 @@
+// @ts-check
+///
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const { pathToFileURL } = require("url");
+
+/**
+ * @typedef {{ name: string, path: string, patterns?: string[], context?: string }} QmdCheckout
+ * @typedef {{ name?: string, type?: string, query?: string, repo?: string, min?: number, max?: number, tokenEnvVar?: string }} QmdSearch
+ * @typedef {{ dbPath: string, checkouts?: QmdCheckout[], searches?: QmdSearch[] }} QmdConfig
+ */
+
+/**
+ * Resolves `${ENV_VAR}` placeholders in a path string using the current process environment.
+ * @param {string} p
+ * @returns {string}
+ */
+function resolveEnvVars(p) {
+ return p.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || "");
+}
+
+/**
+ * Returns an Octokit client for the given token env var, or the default github client.
+ * @param {string | undefined} tokenEnvVar
+ * @returns {typeof github}
+ */
+function getClient(tokenEnvVar) {
+ if (tokenEnvVar && process.env[tokenEnvVar]) {
+ const { getOctokit } = require("@actions/github");
+ return getOctokit(process.env[tokenEnvVar]);
+ }
+ return github;
+}
+
+/**
+ * Writes the step summary to $GITHUB_STEP_SUMMARY via core.summary.
+ * @param {QmdConfig} config
+ * @param {{ indexed: number, updated: number, unchanged: number, removed: number } | null} updateResult
+ * @param {{ embedded: number } | null} embedResult
+ */
+async function writeSummary(config, updateResult, embedResult) {
+ try {
+ let md = "## qmd documentation index\n\n";
+
+ if ((config.checkouts || []).length > 0) {
+ md += "### Collections\n\n";
+ md += "| Name | Patterns | Context |\n";
+ md += "| --- | --- | --- |\n";
+ for (const col of config.checkouts) {
+ const patterns = (col.patterns || ["**/*.md"]).join(", ");
+ const ctx = col.context || "-";
+ md += `| ${col.name} | ${patterns} | ${ctx} |\n`;
+ }
+ md += "\n";
+ }
+
+ if ((config.searches || []).length > 0) {
+ md += "### Searches\n\n";
+ md += "| Name | Type | Query / Repo | Min | Max |\n";
+ md += "| --- | --- | --- | --- | --- |\n";
+ for (const s of config.searches) {
+ const name = s.name || "-";
+ const type = s.type || "code";
+ const ref = (s.query || s.repo || "-").replace(/\|/g, "\\|");
+ const min = s.min > 0 ? String(s.min) : "-";
+ const max = String(s.max > 0 ? s.max : type === "issues" ? 500 : 30);
+ md += `| ${name} | ${type} | ${ref} | ${min} | ${max} |\n`;
+ }
+ md += "\n";
+ }
+
+ if (updateResult) {
+ md += "### Index stats\n\n";
+ md += "| Stat | Value |\n";
+ md += "| --- | --- |\n";
+ md += `| Indexed | ${updateResult.indexed} |\n`;
+ md += `| Updated | ${updateResult.updated} |\n`;
+ md += `| Unchanged | ${updateResult.unchanged} |\n`;
+ md += `| Removed | ${updateResult.removed} |\n`;
+ if (embedResult) {
+ md += `| Embedded | ${embedResult.embedded} |\n`;
+ }
+ }
+
+ await core.summary.addRaw(md).write();
+ } catch (/** @type {any} */ err) {
+ core.warning(`Could not write step summary: ${err.message}`);
+ }
+}
+
+/**
+ * Main entry point for building the qmd documentation index.
+ *
+ * Reads the JSON config from the QMD_CONFIG_JSON environment variable, uses the
+ * @tobilu/qmd JavaScript SDK to create a vector-search store, registers all
+ * configured collections (from checkouts and GitHub searches), then calls
+ * store.update() and store.embed() to index the files and save the collection.
+ *
+ * Called from an actions/github-script step via:
+ * const { main } = require('/tmp/gh-aw/actions/qmd_index.cjs');
+ * await main();
+ */
+async function main() {
+ const configJson = process.env.QMD_CONFIG_JSON;
+ if (!configJson) {
+ core.setFailed("QMD_CONFIG_JSON environment variable not set");
+ return;
+ }
+
+ /** @type {QmdConfig} */
+ const config = JSON.parse(configJson);
+
+ // Load @tobilu/qmd SDK (ESM-only package) via dynamic import.
+ // The package is installed into the gh-aw actions directory by a prior npm-install step.
+ const qmdIndexPath = path.join(__dirname, "node_modules", "@tobilu", "qmd", "dist", "index.js");
+ if (!fs.existsSync(qmdIndexPath)) {
+ core.setFailed(`@tobilu/qmd not found at ${qmdIndexPath}. The 'Install @tobilu/qmd SDK' step must run first.`);
+ return;
+ }
+
+ const { createStore } = /** @type {any} */ await import(pathToFileURL(qmdIndexPath).href);
+
+ // Ensure the index directory exists.
+ fs.mkdirSync(config.dbPath, { recursive: true });
+ const dbPath = path.join(config.dbPath, "index.sqlite");
+
+ // ── Build collections config from checkout entries ──────────────────────
+ /** @type {Record }>} */
+ const collections = {};
+
+ for (const checkout of config.checkouts || []) {
+ const resolvedPath = resolveEnvVars(checkout.path);
+ const pattern = (checkout.patterns || ["**/*.md"]).join(",");
+ collections[checkout.name] = {
+ path: resolvedPath,
+ pattern,
+ ...(checkout.context ? { context: { "/": checkout.context } } : {}),
+ };
+ }
+
+ // ── Process search entries ───────────────────────────────────────────────
+ for (let i = 0; i < (config.searches || []).length; i++) {
+ const search = config.searches[i];
+ const collectionName = search.name || `search-${i}`;
+ const searchDir = `/tmp/gh-aw/qmd-search-${i}`;
+ fs.mkdirSync(searchDir, { recursive: true });
+
+ const client = getClient(search.tokenEnvVar);
+
+ if (search.type === "issues") {
+ const repoSlug = search.repo || process.env.GITHUB_REPOSITORY || "";
+ const slugParts = repoSlug.split("/");
+ if (slugParts.length < 2 || !slugParts[0] || !slugParts[1]) {
+ core.setFailed(`qmd search "${collectionName}": invalid repository slug "${repoSlug}" (expected "owner/repo")`);
+ return;
+ }
+ const [owner, repo] = slugParts;
+ const maxCount = search.max > 0 ? search.max : 500;
+
+ core.info(`Fetching issues from ${repoSlug} (max: ${maxCount})…`);
+
+ // Paginate until we have accumulated enough issues across all pages.
+ let accumulated = 0;
+ const issues = await client.paginate(client.rest.issues.listForRepo, { owner, repo, state: "open", per_page: 100 }, (/** @type {{ data: any[] }} */ response, done) => {
+ accumulated += response.data.length;
+ if (accumulated >= maxCount) done();
+ return response.data;
+ });
+
+ const slice = issues.slice(0, maxCount);
+ for (const issue of slice) {
+ const content = `## ${issue.number}: ${issue.title}\n\n${issue.body || ""}`;
+ fs.writeFileSync(path.join(searchDir, `issue-${issue.number}.md`), content, "utf8");
+ }
+ core.info(`Saved ${slice.length} issues to ${searchDir}`);
+ } else {
+ // Code search: download matching files via GitHub REST API.
+ const maxCount = search.max > 0 ? search.max : 30;
+ core.info(`Searching GitHub code: "${search.query}" (max: ${maxCount})…`);
+
+ const response = await client.rest.search.code({
+ q: search.query,
+ per_page: Math.min(maxCount, 100),
+ });
+
+ let downloaded = 0;
+ for (const item of response.data.items) {
+ const fullNameParts = item.repository.full_name.split("/");
+ if (fullNameParts.length < 2) continue;
+ const [owner, repo] = fullNameParts;
+ try {
+ const fileResp = await client.rest.repos.getContent({
+ owner,
+ repo,
+ path: item.path,
+ });
+ const data = /** @type {any} */ fileResp.data;
+ if (data.type === "file" && data.content) {
+ const fileContent = Buffer.from(data.content, "base64").toString("utf8");
+ const safeName = `${owner}-${repo}-${item.path.replace(/\//g, "-")}`;
+ fs.writeFileSync(path.join(searchDir, safeName), fileContent, "utf8");
+ downloaded++;
+ }
+ } catch (/** @type {any} */ err) {
+ core.warning(`Could not download ${item.repository.full_name}/${item.path}: ${err.message}`);
+ }
+ }
+ core.info(`Downloaded ${downloaded} files to ${searchDir}`);
+ }
+
+ // Enforce minimum result count.
+ if (search.min > 0) {
+ const fileCount = fs.readdirSync(searchDir).length;
+ if (fileCount < search.min) {
+ core.setFailed(`qmd search "${collectionName}" returned ${fileCount} results, minimum is ${search.min}`);
+ return;
+ }
+ }
+
+ collections[collectionName] = {
+ path: searchDir,
+ pattern: "**/*",
+ };
+ }
+
+ // ── Create store and build index ─────────────────────────────────────────
+ core.info(`Creating qmd store at ${dbPath}…`);
+
+ // Set QMD_CACHE_DIR so the SDK stores model weights in the index directory.
+ process.env.QMD_CACHE_DIR = config.dbPath;
+
+ const store = await createStore({ dbPath, config: { collections } });
+
+ let updateResult = null;
+ let embedResult = null;
+
+ try {
+ core.info("Indexing files (update)…");
+ updateResult = await store.update({
+ onProgress: (/** @type {{ collection: string, file: string, current: number, total: number }} */ info) => {
+ if (info.current % 50 === 0 || info.current === info.total) {
+ core.debug(`[${info.collection}] ${info.current}/${info.total}: ${info.file}`);
+ }
+ },
+ });
+ core.info(`Update complete: ${updateResult.indexed} indexed, ${updateResult.updated} updated, ` + `${updateResult.unchanged} unchanged, ${updateResult.removed} removed`);
+
+ core.info("Generating embeddings (embed)…");
+ embedResult = await store.embed({
+ onProgress: (/** @type {{ current: number, total: number }} */ info) => {
+ if (info.current % 20 === 0 || info.current === info.total) {
+ core.debug(`Embedding ${info.current}/${info.total}`);
+ }
+ },
+ });
+ core.info(`Embed complete: ${embedResult.embedded} embedded`);
+ } finally {
+ await store.close();
+ await writeSummary(config, updateResult, embedResult);
+ }
+
+ core.info("qmd index built successfully");
+}
+
+module.exports = { main };
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 002fe183747..0b0b8014d26 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -10,6 +10,8 @@
// 1. Activation job: builds the search index from configured checkouts and/or GitHub searches
// and uploads it as the "qmd-index" artifact. This step runs in the activation job which
// already has contents:read permission, so the agent job does NOT need contents:read.
+// The index is built by a single actions/github-script step that runs qmd_index.cjs,
+// which uses the @tobilu/qmd JavaScript SDK to build the collections.
//
// 2. Agent job: downloads the "qmd-index" artifact and mounts the qmd MCP server pointing
// at the pre-built index. The MCP server exposes a search tool that the agent can use
@@ -54,12 +56,13 @@
// - mcp_renderer_builtin.go: RenderQmdMCP method
// - compiler_activation_job.go: activation job qmd index steps
// - compiler_yaml_main_job.go: agent job qmd artifact download
+// - actions/setup/js/qmd_index.cjs: JavaScript SDK implementation
package workflow
import (
+ "encoding/json"
"fmt"
- "strconv"
"strings"
"github.com/github/gh-aw/pkg/constants"
@@ -68,15 +71,6 @@ import (
var qmdLog = logger.New("workflow:qmd")
-// shellSingleQuote wraps s in POSIX single quotes, escaping any embedded single
-// quotes via the '"'"' idiom. The result is safe to interpolate directly into
-// a shell command: no shell metacharacters ($, `, \, ;, |, etc.) are
-// interpreted inside single-quoted strings.
-func shellSingleQuote(s string) string {
- // Replace each ' with '\'' (end-quote, literal-apostrophe, re-open-quote)
- return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
-}
-
// hasQmdTool checks if the qmd tool is enabled in the tools configuration.
func hasQmdTool(parsedTools *Tools) bool {
if parsedTools == nil {
@@ -148,46 +142,96 @@ func generateQmdCacheSaveStep(cacheKey string) string {
return sb.String()
}
-// resolvedQmdCollection is an internal representation of a qmd collection
-// with its working directory resolved.
-type resolvedQmdCollection struct {
- name string
- paths []string
- context string
- workdir string // absolute path within the runner (e.g. ${GITHUB_WORKSPACE} or /tmp/gh-aw/qmd-checkout-)
+// qmdCheckoutEntry is the JSON representation of a checkout-based collection
+// passed to qmd_index.cjs via the QMD_CONFIG_JSON environment variable.
+type qmdCheckoutEntry struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Patterns []string `json:"patterns,omitempty"`
+ Context string `json:"context,omitempty"`
+}
+
+// qmdSearchEntry is the JSON representation of a search entry passed to qmd_index.cjs.
+type qmdSearchEntry struct {
+ Name string `json:"name,omitempty"`
+ Type string `json:"type,omitempty"` // "code" (default) or "issues"
+ Query string `json:"query,omitempty"` // for "code" type
+ Repo string `json:"repo,omitempty"` // for "issues" type; blank = github.repository
+ Min int `json:"min,omitempty"` // minimum result count (0 = no minimum)
+ Max int `json:"max,omitempty"` // maximum result count (0 = use default)
+ TokenEnvVar string `json:"tokenEnvVar,omitempty"` // env var holding custom GitHub token
+}
+
+// qmdBuildConfig is the top-level JSON config serialised into QMD_CONFIG_JSON
+// and consumed by actions/setup/js/qmd_index.cjs.
+type qmdBuildConfig struct {
+ DBPath string `json:"dbPath"`
+ Checkouts []qmdCheckoutEntry `json:"checkouts,omitempty"`
+ Searches []qmdSearchEntry `json:"searches,omitempty"`
+}
+
+// resolveQmdWorkdir returns the working directory path for a checkout-based collection.
+// Returns "${GITHUB_WORKSPACE}" for the default (current) repository, or the path
+// specified / derived from the checkout config for external repositories.
+func resolveQmdWorkdir(col *QmdDocCollection) string {
+ if col.Checkout == nil {
+ return "${GITHUB_WORKSPACE}"
+ }
+ if col.Checkout.Path != "" {
+ checkoutPath := strings.TrimPrefix(col.Checkout.Path, "./")
+ return "${GITHUB_WORKSPACE}/" + checkoutPath
+ }
+ name := col.Name
+ if name == "" {
+ name = "docs"
+ }
+ return "/tmp/gh-aw/qmd-checkout-" + name
}
-// resolveQmdCheckouts converts the checkouts portion of a QmdToolConfig
-// into a list of resolvedQmdCollections.
-func resolveQmdCheckouts(qmdConfig *QmdToolConfig) []resolvedQmdCollection {
- if len(qmdConfig.Checkouts) == 0 {
- return nil
+// buildQmdConfig constructs the qmdBuildConfig from the user-provided QmdToolConfig.
+func buildQmdConfig(qmdConfig *QmdToolConfig) qmdBuildConfig {
+ cfg := qmdBuildConfig{
+ DBPath: "/tmp/gh-aw/qmd-index",
}
- resolved := make([]resolvedQmdCollection, 0, len(qmdConfig.Checkouts))
+
for _, col := range qmdConfig.Checkouts {
name := col.Name
if name == "" {
name = "docs"
}
- workdir := "${GITHUB_WORKSPACE}"
- if col.Checkout != nil {
- if col.Checkout.Path != "" {
- // Checkout path is relative to GITHUB_WORKSPACE; strip leading "./" for cleanliness
- checkoutPath := strings.TrimPrefix(col.Checkout.Path, "./")
- workdir = "${GITHUB_WORKSPACE}/" + checkoutPath
- } else {
- // No explicit path → use an isolated temp directory
- workdir = "/tmp/gh-aw/qmd-checkout-" + name
- }
+ entry := qmdCheckoutEntry{
+ Name: name,
+ Path: resolveQmdWorkdir(col),
+ Context: col.Context,
+ }
+ if len(col.Paths) > 0 {
+ entry.Patterns = col.Paths
}
- resolved = append(resolved, resolvedQmdCollection{
- name: name,
- paths: col.Paths,
- context: col.Context,
- workdir: workdir,
- })
+ cfg.Checkouts = append(cfg.Checkouts, entry)
}
- return resolved
+
+ for i, s := range qmdConfig.Searches {
+ name := s.Name
+ if name == "" {
+ name = fmt.Sprintf("search-%d", i)
+ }
+ entry := qmdSearchEntry{
+ Name: name,
+ Type: s.Type,
+ Query: s.Query,
+ Min: s.Min,
+ Max: s.Max,
+ }
+ if s.Type == "issues" && s.Query != "" {
+ entry.Repo = s.Query
+ }
+ if s.GitHubToken != "" {
+ entry.TokenEnvVar = fmt.Sprintf("QMD_SEARCH_TOKEN_%d", i)
+ }
+ cfg.Searches = append(cfg.Searches, entry)
+ }
+
+ return cfg
}
// generateQmdCollectionCheckoutStep generates a checkout step YAML string for a qmd
@@ -238,134 +282,20 @@ func generateQmdCollectionCheckoutStep(col *QmdDocCollection) string {
return sb.String()
}
-// generateQmdSearchStep generates an activation-job step that runs a GitHub search or issue
-// list, saves the results as individual files, and adds them as a named qmd collection.
-// When entry.Type is "issues", it uses `gh issue list` to fetch open issues from the
-// repository and formats each as a markdown file. Otherwise (default "code" type) it uses
-// `gh search code` to find repository files.
-func generateQmdSearchStep(entry *QmdSearchEntry, index int) string {
- collectionName := entry.Name
- if collectionName == "" {
- collectionName = fmt.Sprintf("search-%d", index)
- }
-
- if entry.Type == "issues" {
- return generateQmdIssueListStep(entry, collectionName, index)
- }
- return generateQmdCodeSearchStep(entry, collectionName, index)
-}
-
-// generateQmdIssueListStep generates a step that fetches open GitHub issues from a
-// repository using `gh issue list` and saves each issue as a markdown file so they
-// can be indexed by qmd.
-func generateQmdIssueListStep(entry *QmdSearchEntry, collectionName string, index int) string {
- searchDir := fmt.Sprintf("/tmp/gh-aw/qmd-search-%d", index)
-
- maxResults := entry.Max
- if maxResults <= 0 {
- maxResults = 500
- }
-
- repo := entry.Query
- if repo == "" {
- repo = "${{ github.repository }}"
- }
-
- var tokenEnv string
- if entry.GitHubToken != "" {
- tokenEnv = fmt.Sprintf("GH_TOKEN=%s ", entry.GitHubToken)
- }
-
- var sb strings.Builder
- fmt.Fprintf(&sb, " - name: Fetch GitHub issues for qmd collection %q\n", collectionName)
- sb.WriteString(" run: |\n")
- sb.WriteString(" set -e\n")
- fmt.Fprintf(&sb, " mkdir -p %s\n", searchDir)
- sb.WriteString(" # Fetch open issues and save each as a markdown file\n")
- fmt.Fprintf(&sb, " %sgh issue list --repo %s --state open --limit %d --json number,title,body | \\\n",
- tokenEnv, shellSingleQuote(repo), maxResults)
- fmt.Fprintf(&sb, " jq -r '.[] | \"## \" + (.number | tostring) + \": \" + .title + \"\\n\\n\" + (.body // \"\") | @text' | \\\n")
- fmt.Fprintf(&sb, " awk 'BEGIN{n=0} /^## [0-9]+:/{n++; file=\"%s/issue-\" n \".md\"} {print > file}'\n", searchDir)
-
- if entry.Min > 0 {
- fmt.Fprintf(&sb, " count=$(find %s -type f | wc -l)\n", searchDir)
- fmt.Fprintf(&sb, " if [ \"$count\" -lt %d ]; then\n", entry.Min)
- fmt.Fprintf(&sb, " echo \"qmd issue list %q returned $count results, minimum is %d\" >&2\n", collectionName, entry.Min)
- sb.WriteString(" exit 1\n")
- sb.WriteString(" fi\n")
- }
-
- fmt.Fprintf(&sb, " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add %s --name %s --glob %s\n",
- shellSingleQuote(searchDir),
- shellSingleQuote(collectionName),
- "'**/*'",
- )
- return sb.String()
-}
-
-// generateQmdCodeSearchStep generates an activation-job step that runs a GitHub code
-// search query, downloads the matching files, and adds them as a named qmd collection.
-func generateQmdCodeSearchStep(entry *QmdSearchEntry, collectionName string, index int) string {
- searchDir := fmt.Sprintf("/tmp/gh-aw/qmd-search-%d", index)
-
- maxResults := entry.Max
- if maxResults <= 0 {
- maxResults = 30
- }
-
- // Build the GH_TOKEN env override if a custom token is provided
- var tokenEnv string
- if entry.GitHubToken != "" {
- tokenEnv = fmt.Sprintf("GH_TOKEN=%s ", entry.GitHubToken)
- }
-
- var sb strings.Builder
- fmt.Fprintf(&sb, " - name: Search GitHub for qmd collection %q\n", collectionName)
- sb.WriteString(" run: |\n")
- sb.WriteString(" set -e\n")
- fmt.Fprintf(&sb, " mkdir -p %s\n", searchDir)
-
- // Execute gh search code, download each result file, then register the collection
- sb.WriteString(" # Download search results and add them to the qmd index\n")
- fmt.Fprintf(&sb, " %sgh search code %s --limit %d --json path,repository | \\\n",
- tokenEnv,
- shellSingleQuote(entry.Query),
- maxResults,
- )
- // Use jq to extract repo+path pairs and download each file via gh api
- fmt.Fprintf(&sb, " jq -r '.[] | .repository.fullName + \" \" + .path' | \\\n")
- fmt.Fprintf(&sb, " while IFS=' ' read -r repo file_path; do\n")
- fmt.Fprintf(&sb, " dest=%s/\"${repo//\\//-}\"-\"${file_path//\\//-}\"\n", searchDir)
- fmt.Fprintf(&sb, " %sgh api \"repos/$repo/contents/$file_path\" --jq '.content' | base64 -d > \"$dest\" 2>/dev/null || true\n", tokenEnv)
- fmt.Fprintf(&sb, " done\n")
-
- // Enforce minimum count
- if entry.Min > 0 {
- fmt.Fprintf(&sb, " count=$(find %s -type f | wc -l)\n", searchDir)
- fmt.Fprintf(&sb, " if [ \"$count\" -lt %d ]; then\n", entry.Min)
- fmt.Fprintf(&sb, " echo \"qmd search %q returned $count results, minimum is %d\" >&2\n", collectionName, entry.Min)
- sb.WriteString(" exit 1\n")
- sb.WriteString(" fi\n")
- }
-
- // Add the downloaded files as a qmd collection
- fmt.Fprintf(&sb, " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add %s --name %s --glob %s\n",
- shellSingleQuote(searchDir),
- shellSingleQuote(collectionName),
- "'**/*'",
- )
-
- return sb.String()
-}
-
-// generateQmdIndexSteps generates the activation job steps that install qmd, register
-// collections for each configured checkout and/or search, and build the vector search index.
-// The index is stored at /tmp/gh-aw/qmd-index and uploaded as the qmd-index artifact.
+// generateQmdIndexSteps generates the activation job steps that install the @tobilu/qmd SDK,
+// run the qmd_index.cjs JavaScript script to build the vector search index, and upload it
+// as the qmd-index artifact.
+//
+// The configuration is serialised to JSON and passed via the QMD_CONFIG_JSON environment
+// variable to the github-script step. qmd_index.cjs uses the @tobilu/qmd SDK to:
+// 1. Register checkout-based collections
+// 2. Fetch GitHub search/issue results and register them as collections
+// 3. Call store.update() and store.embed() to index and embed all documents
//
// When qmdConfig.CacheKey is set:
// - A cache restore step is always emitted first.
// - In read-only mode (no sources): only the cache restore + artifact upload are emitted;
-// Node.js, qmd installation, and indexing steps are skipped entirely.
+// Node.js, qmd SDK installation, and indexing steps are skipped entirely.
// - In build mode (sources present): indexing steps are guarded by
// `if: steps.qmd-cache-restore.outputs.cache-hit != 'true'`, so they are skipped on a
// cache hit. A cache save step follows the indexing steps.
@@ -389,88 +319,78 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
// Cache-only mode: no indexing at all — just use the restored cache
if isCacheOnlyMode {
qmdLog.Print("qmd cache-only mode: skipping indexing, using cache only")
- // Fall through to artifact upload below
} else {
// Conditional prefix for build steps when cache-key is set (skip on cache hit)
- var ifCacheMiss string
+ ifCacheMiss := ""
if qmdConfig.CacheKey != "" {
ifCacheMiss = " if: steps.qmd-cache-restore.outputs.cache-hit != 'true'\n"
}
- // Setup Node.js (required to run npm/npx)
- steps = append(steps, " - name: Setup Node.js for qmd\n")
+ // Setup Node.js (required to run the qmd SDK)
+ nodeSetup := " - name: Setup Node.js for qmd\n"
if ifCacheMiss != "" {
- steps = append(steps, ifCacheMiss)
+ nodeSetup += ifCacheMiss
}
- steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/setup-node")))
- steps = append(steps, " with:\n")
- steps = append(steps, fmt.Sprintf(" node-version: \"%s\"\n", string(constants.DefaultNodeVersion)))
-
- // Install qmd globally
- steps = append(steps, " - name: Install qmd\n")
+ nodeSetup += fmt.Sprintf(" uses: %s\n", GetActionPin("actions/setup-node"))
+ nodeSetup += " with:\n"
+ nodeSetup += fmt.Sprintf(" node-version: \"%s\"\n", string(constants.DefaultNodeVersion))
+ steps = append(steps, nodeSetup)
+
+ // Install the @tobilu/qmd SDK into the gh-aw actions directory so qmd_index.cjs
+ // can require('@tobilu/qmd') via the adjacent node_modules folder.
+ npmInstall := " - name: Install @tobilu/qmd SDK\n"
if ifCacheMiss != "" {
- steps = append(steps, ifCacheMiss)
+ npmInstall += ifCacheMiss
}
- steps = append(steps, " run: |\n")
- steps = append(steps, fmt.Sprintf(" npm install -g @tobilu/qmd@%s\n", version))
+ npmInstall += " run: |\n"
+ npmInstall += fmt.Sprintf(" npm install --prefix \"${{ runner.temp }}/gh-aw/actions\" @tobilu/qmd@%s\n", version)
+ steps = append(steps, npmInstall)
- // Emit a checkout step for each checkout-based collection that needs its own repo
+ // Emit a checkout step for each collection that targets a non-default repository
for _, col := range qmdConfig.Checkouts {
if checkoutStep := generateQmdCollectionCheckoutStep(col); checkoutStep != "" {
steps = append(steps, checkoutStep)
}
}
- // Build the index: create the cache dir and register all collections
- steps = append(steps, " - name: Build qmd index\n")
+ // Build the JSON configuration for qmd_index.cjs
+ cfg := buildQmdConfig(qmdConfig)
+ cfgJSON, err := json.Marshal(cfg)
+ if err != nil {
+ qmdLog.Printf("Failed to marshal qmd config: %v", err)
+ cfgJSON = []byte("{}")
+ }
+
+ // Generate the github-script step that runs qmd_index.cjs
+ var scriptSB strings.Builder
+ scriptSB.WriteString(" - name: Build qmd index\n")
if ifCacheMiss != "" {
- steps = append(steps, ifCacheMiss)
+ scriptSB.WriteString(ifCacheMiss)
}
- steps = append(steps, " run: |\n")
- steps = append(steps, " set -e\n")
- steps = append(steps, " mkdir -p /tmp/gh-aw/qmd-index\n")
-
- // Register each checkout-based collection.
- // The workdir is double-quoted to preserve ${GITHUB_WORKSPACE} variable expansion.
- // User-provided names, globs, and context are POSIX single-quoted to prevent shell injection.
- checkouts := resolveQmdCheckouts(qmdConfig)
- for _, col := range checkouts {
- var globArg string
- if len(col.paths) > 0 {
- globArg = shellSingleQuote(strings.Join(col.paths, ","))
- } else {
- globArg = "'**/*.md'"
- }
- if col.context != "" {
- steps = append(steps, fmt.Sprintf(
- " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"%s\" --name %s --glob %s --context %s\n",
- col.workdir,
- shellSingleQuote(col.name),
- globArg,
- shellSingleQuote(col.context),
- ))
- } else {
- steps = append(steps, fmt.Sprintf(
- " QMD_CACHE_DIR=/tmp/gh-aw/qmd-index qmd collection add \"%s\" --name %s --glob %s\n",
- col.workdir,
- shellSingleQuote(col.name),
- globArg,
- ))
+ fmt.Fprintf(&scriptSB, " uses: %s\n", GetActionPin("actions/github-script"))
+ scriptSB.WriteString(" env:\n")
+ // Pass the config JSON as an env var; the YAML literal block avoids quoting issues
+ scriptSB.WriteString(" QMD_CONFIG_JSON: |\n")
+ fmt.Fprintf(&scriptSB, " %s\n", string(cfgJSON))
+ // Add per-search custom token env vars
+ for i, s := range qmdConfig.Searches {
+ if s.GitHubToken != "" {
+ fmt.Fprintf(&scriptSB, " QMD_SEARCH_TOKEN_%d: %s\n", i, s.GitHubToken)
}
}
-
- // Emit a step per GitHub search entry
- for i, search := range qmdConfig.Searches {
- steps = append(steps, generateQmdSearchStep(search, i))
- }
+ scriptSB.WriteString(" with:\n")
+ scriptSB.WriteString(" github-token: ${{ github.token }}\n")
+ scriptSB.WriteString(" script: |\n")
+ fmt.Fprintf(&scriptSB, " const { setupGlobals } = require('%s/setup_globals.cjs');\n", SetupActionDestination)
+ scriptSB.WriteString(" setupGlobals(core, github, context, exec, io);\n")
+ fmt.Fprintf(&scriptSB, " const { main } = require('%s/qmd_index.cjs');\n", SetupActionDestination)
+ scriptSB.WriteString(" await main();\n")
+ steps = append(steps, scriptSB.String())
// If cache-key is set, save the freshly-built index to cache (skipped on hit)
if qmdConfig.CacheKey != "" {
steps = append(steps, generateQmdCacheSaveStep(qmdConfig.CacheKey))
}
-
- // Write a summary of all indexed collections to the step summary
- steps = append(steps, generateQmdSummaryStep(qmdConfig))
}
// Upload qmd index as a separate artifact for the agent job
@@ -487,80 +407,6 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
return steps
}
-// generateQmdSummaryStep generates a step that writes a Markdown summary of the qmd
-// documentation collections to $GITHUB_STEP_SUMMARY so reviewers can see what was indexed.
-// The table lists each checkout collection (name, paths, context) and each search entry (query).
-func generateQmdSummaryStep(qmdConfig *QmdToolConfig) string {
- var sb strings.Builder
- sb.WriteString(" - name: Summarize qmd index\n")
- sb.WriteString(" if: always()\n")
- sb.WriteString(" run: |\n")
- sb.WriteString(" {\n")
- sb.WriteString(" echo '## qmd documentation index'\n")
- sb.WriteString(" echo ''\n")
-
- // Checkout-based collections
- checkouts := resolveQmdCheckouts(qmdConfig)
- if len(checkouts) > 0 {
- sb.WriteString(" echo '### Collections'\n")
- sb.WriteString(" echo ''\n")
- sb.WriteString(" echo '| Name | Paths | Context |'\n")
- sb.WriteString(" echo '| --- | --- | --- |'\n")
- for _, col := range checkouts {
- pathsStr := strings.Join(col.paths, ", ")
- if pathsStr == "" {
- pathsStr = "**/*.md"
- }
- contextStr := col.context
- if contextStr == "" {
- contextStr = "-"
- }
- fmt.Fprintf(&sb, " echo '| %s | %s | %s |'\n",
- shellSingleQuoteInRun(col.name),
- shellSingleQuoteInRun(pathsStr),
- shellSingleQuoteInRun(contextStr),
- )
- }
- sb.WriteString(" echo ''\n")
- }
-
- // Search entries
- if len(qmdConfig.Searches) > 0 {
- sb.WriteString(" echo '### Searches'\n")
- sb.WriteString(" echo ''\n")
- sb.WriteString(" echo '| Query | Min | Max |'\n")
- sb.WriteString(" echo '| --- | --- | --- |'\n")
- for _, s := range qmdConfig.Searches {
- minStr := "-"
- if s.Min > 0 {
- minStr = strconv.Itoa(s.Min)
- }
- maxStr := "30"
- if s.Max > 0 {
- maxStr = strconv.Itoa(s.Max)
- }
- fmt.Fprintf(&sb, " echo '| %s | %s | %s |'\n",
- shellSingleQuoteInRun(s.Query),
- minStr,
- maxStr,
- )
- }
- sb.WriteString(" echo ''\n")
- }
-
- sb.WriteString(" } >> $GITHUB_STEP_SUMMARY\n")
- return sb.String()
-}
-
-// shellSingleQuoteInRun escapes a string for safe embedding inside an already-single-quoted
-// shell echo argument used in run: blocks. Pipes (|) are escaped to prevent Markdown table
-// column breaks and single quotes are neutralized via the '"'"' idiom.
-func shellSingleQuoteInRun(s string) string {
- s = strings.ReplaceAll(s, "|", "\\|")
- s = strings.ReplaceAll(s, "'", `'"'"'`)
- return s
-}
-
// generateQmdDownloadStep generates the agent job step that downloads the qmd-index artifact.
// Returns the steps as a YAML string slice ready to be appended to the agent job steps.
func generateQmdDownloadStep(data *WorkflowData) string {
From 658c7f63f91b20dd64440db6e8501d95842f1269 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 04:41:44 +0000
Subject: [PATCH 15/49] test: add qmd_index.test.cjs covering all code paths +
js-qmd-index CI job
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds 20 vitest unit tests in actions/setup/js/qmd_index.test.cjs covering:
- Missing QMD_CONFIG_JSON → setFailed
- Missing @tobilu/qmd SDK → setFailed
- Checkout collections: basic, ${ENV_VAR} path expansion, default pattern
- Issues search: valid repo, explicit repo field, invalid slug, empty slug, min-count failure
- Code search: file download, min-count failure, download errors (warning), malformed full_name (skip)
- Combined checkouts + searches
- store.close() always called in finally block
- writeSummary: checkouts table, searches table, index stats, error handling
When @tobilu/qmd is not installed, beforeAll creates a minimal ESM fake at
node_modules/@tobilu/qmd/dist/index.js that returns globalThis.__qmdMockStore__.
Each test sets this global in beforeEach. Uses createRequire() (not eval()) to
allow the dynamic await import(fileUrl) in the script to work.
Also adds js-qmd-index job to .github/workflows/ci.yml that runs these
tests in isolation after validate-yaml.
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/5c4da1f8-aba2-40dd-b076-e13d3a9f8c77
---
.github/workflows/ci.yml | 31 ++
actions/setup/js/qmd_index.test.cjs | 484 ++++++++++++++++++++++++++++
2 files changed, 515 insertions(+)
create mode 100644 actions/setup/js/qmd_index.test.cjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1b16d002f0f..ec412261851 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -871,6 +871,37 @@ jobs:
- name: Run tests
run: cd actions/setup/js && npm test
+ js-qmd-index:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ needs: validate-yaml
+ permissions:
+ contents: read
+ concurrency:
+ group: ci-${{ github.ref }}-js-qmd-index
+ cancel-in-progress: true
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Set up Node.js
+ id: setup-node
+ uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
+ with:
+ node-version: "24"
+ cache: npm
+ cache-dependency-path: actions/setup/js/package-lock.json
+ - name: Report Node cache status
+ run: |
+ if [ "${{ steps.setup-node.outputs.cache-hit }}" == "true" ]; then
+ echo "✅ Node cache hit" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "⚠️ Node cache miss" >> $GITHUB_STEP_SUMMARY
+ fi
+ - name: Install npm dependencies
+ run: cd actions/setup/js && npm ci
+ - name: Run qmd_index.cjs tests
+ run: cd actions/setup/js && npm test -- qmd_index.test.cjs
+
js-integration-live-api:
runs-on: ubuntu-latest
timeout-minutes: 10
diff --git a/actions/setup/js/qmd_index.test.cjs b/actions/setup/js/qmd_index.test.cjs
new file mode 100644
index 00000000000..682cdd01816
--- /dev/null
+++ b/actions/setup/js/qmd_index.test.cjs
@@ -0,0 +1,484 @@
+// @ts-check
+///
+import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, vi } from "vitest";
+import fs from "fs";
+import path from "path";
+import os from "os";
+import { createRequire } from "module";
+
+// --- Fake @tobilu/qmd SDK setup -----------------------------------------------
+//
+// qmd_index.cjs dynamically imports the SDK via:
+// await import(pathToFileURL(path.join(__dirname, "node_modules/@tobilu/qmd/dist/index.js")))
+//
+// When the real package is not installed we create a minimal fake ESM module at
+// that path so the dynamic import succeeds. The fake createStore() returns
+// globalThis.__qmdMockStore__, which is set fresh in beforeEach.
+//
+// Node's ES-module cache means the import() is only evaluated once across all
+// tests. Updating globalThis.__qmdMockStore__ between tests is therefore the
+// mechanism for giving each test a fresh mock store.
+//
+// If @tobilu/qmd is already installed (e.g. in a dedicated CI integration job)
+// the fake is not created; the real SDK's createStore() would be called, but
+// because this scenario is only encountered in a CI job that specifically
+// installs the package, both the unit tests (fake SDK) and the integration CI
+// job (real SDK with a minimal fixture) are covered.
+
+const SCRIPT_DIR = import.meta.dirname;
+const SDK_DIST_DIR = path.join(SCRIPT_DIR, "node_modules", "@tobilu", "qmd", "dist");
+const SDK_PATH = path.join(SDK_DIST_DIR, "index.js");
+const SDK_PKG_PATH = path.join(SCRIPT_DIR, "node_modules", "@tobilu", "qmd", "package.json");
+
+const sdkAlreadyInstalled = fs.existsSync(SDK_PATH);
+
+// Minimal ESM module that proxies through the per-test mock store global.
+const FAKE_SDK_ESM = `export async function createStore() {
+ return globalThis.__qmdMockStore__;
+}
+`;
+const FAKE_SDK_PKG = JSON.stringify({ type: "module", main: "dist/index.js" });
+
+// --- Load module under test ---------------------------------------------------
+//
+// Load once; globals (core, github) and process.env are read at call time so
+// changing them in beforeEach / afterEach affects each test independently.
+
+const _require = createRequire(import.meta.url);
+const { main } = _require("./qmd_index.cjs");
+
+// --- Helpers ------------------------------------------------------------------
+
+/** Creates a fresh mock store returned by the fake createStore(). */
+function makeMockStore() {
+ return {
+ update: vi.fn().mockResolvedValue({ indexed: 2, updated: 0, unchanged: 0, removed: 0 }),
+ embed: vi.fn().mockResolvedValue({ embedded: 2 }),
+ close: vi.fn().mockResolvedValue(undefined),
+ };
+}
+
+// --- Test suite ---------------------------------------------------------------
+
+describe("qmd_index.cjs", () => {
+ let mockCore;
+ let mockGithub;
+ let mockStore;
+ let tmpDir;
+
+ // ── Global setup: create fake SDK if needed ───────────────────────────────
+ beforeAll(() => {
+ if (!sdkAlreadyInstalled) {
+ // { recursive: true } creates all parent directories (including node_modules)
+ // so this works even in a fresh clone before npm install.
+ fs.mkdirSync(SDK_DIST_DIR, { recursive: true });
+ fs.writeFileSync(SDK_PATH, FAKE_SDK_ESM, "utf8");
+ fs.writeFileSync(SDK_PKG_PATH, FAKE_SDK_PKG, "utf8");
+ }
+ });
+
+ afterAll(() => {
+ if (!sdkAlreadyInstalled) {
+ const tobiluScope = path.join(SCRIPT_DIR, "node_modules", "@tobilu");
+ if (fs.existsSync(tobiluScope)) {
+ fs.rmSync(tobiluScope, { recursive: true, force: true });
+ }
+ }
+ });
+
+ // ── Per-test setup ────────────────────────────────────────────────────────
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qmd-test-"));
+
+ mockStore = makeMockStore();
+ globalThis.__qmdMockStore__ = mockStore;
+
+ mockCore = {
+ info: vi.fn(),
+ debug: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+ summary: {
+ addRaw: vi.fn().mockReturnThis(),
+ write: vi.fn().mockResolvedValue(undefined),
+ },
+ };
+
+ mockGithub = {
+ rest: {
+ issues: { listForRepo: vi.fn() },
+ search: {
+ code: vi.fn().mockResolvedValue({ data: { items: [] } }),
+ },
+ repos: { getContent: vi.fn() },
+ },
+ paginate: vi.fn().mockResolvedValue([]),
+ };
+
+ global.core = mockCore;
+ global.github = mockGithub;
+ delete process.env.QMD_CONFIG_JSON;
+ delete process.env.GITHUB_REPOSITORY;
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ // Clean up qmd search dirs written to /tmp/gh-aw/qmd-search-N by the script.
+ // These paths are hardcoded in qmd_index.cjs (Linux-specific, mirrors the
+ // GitHub Actions runner environment). We clean indices 0-9 which covers
+ // all configs tested here (none use more than 2 search entries).
+ for (let i = 0; i < 10; i++) {
+ const d = `/tmp/gh-aw/qmd-search-${i}`;
+ if (fs.existsSync(d)) fs.rmSync(d, { recursive: true, force: true });
+ }
+ delete globalThis.__qmdMockStore__;
+ delete global.core;
+ delete global.github;
+ vi.restoreAllMocks();
+ });
+
+ // ── Helper ────────────────────────────────────────────────────────────────
+ /**
+ * Sets QMD_CONFIG_JSON and invokes main().
+ * @param {object | undefined} config Pass undefined to leave the env var unset.
+ */
+ async function runMain(config) {
+ if (config !== undefined) {
+ process.env.QMD_CONFIG_JSON = JSON.stringify(config);
+ }
+ await main();
+ }
+
+ // ── Error path: missing config ─────────────────────────────────────────────
+ it("fails when QMD_CONFIG_JSON is not set", async () => {
+ await runMain(undefined);
+ expect(mockCore.setFailed).toHaveBeenCalledWith("QMD_CONFIG_JSON environment variable not set");
+ expect(mockStore.update).not.toHaveBeenCalled();
+ });
+
+ // ── Error path: SDK not installed ─────────────────────────────────────────
+ it("fails when @tobilu/qmd SDK is not found", async () => {
+ const realExistsSync = fs.existsSync.bind(fs);
+ vi.spyOn(fs, "existsSync").mockImplementation(p => {
+ // Use exact path comparison (same value the script computes) rather than
+ // substring search to avoid accidentally suppressing unrelated lookups.
+ if (path.normalize(String(p)) === path.normalize(SDK_PATH)) return false;
+ return realExistsSync(p);
+ });
+
+ await runMain({ dbPath: path.join(tmpDir, "index") });
+
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("@tobilu/qmd not found at"));
+ expect(mockStore.update).not.toHaveBeenCalled();
+ });
+
+ // ── Checkout collection: basic usage ──────────────────────────────────────
+ it("builds index from a checkout collection", async () => {
+ const docsDir = path.join(tmpDir, "docs");
+ fs.mkdirSync(docsDir);
+ fs.writeFileSync(path.join(docsDir, "readme.md"), "# README\nHello world");
+ fs.writeFileSync(path.join(docsDir, "guide.md"), "# Guide\nFoo bar");
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ checkouts: [{ name: "docs", path: docsDir, patterns: ["**/*.md"], context: "Project docs" }],
+ });
+
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ expect(mockStore.update).toHaveBeenCalledOnce();
+ expect(mockStore.embed).toHaveBeenCalledOnce();
+ expect(mockStore.close).toHaveBeenCalledOnce();
+ });
+
+ // ── Checkout collection: env-var expansion ────────────────────────────────
+ it("resolves ${ENV_VAR} placeholders in checkout paths", async () => {
+ const workspaceDir = path.join(tmpDir, "workspace");
+ fs.mkdirSync(workspaceDir);
+ process.env.GITHUB_WORKSPACE = workspaceDir;
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ checkouts: [{ name: "docs", path: "${GITHUB_WORKSPACE}", patterns: ["**/*.md"] }],
+ });
+
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ expect(mockStore.update).toHaveBeenCalledOnce();
+ });
+
+ // ── Checkout collection: default pattern ─────────────────────────────────
+ it("uses **/*.md as the default pattern when none specified", async () => {
+ const docsDir = path.join(tmpDir, "docs");
+ fs.mkdirSync(docsDir);
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ checkouts: [{ name: "docs", path: docsDir }],
+ });
+
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ expect(mockStore.update).toHaveBeenCalledOnce();
+ });
+
+ // ── Issues search: valid repo ─────────────────────────────────────────────
+ it("fetches issues and saves them as markdown files", async () => {
+ process.env.GITHUB_REPOSITORY = "owner/repo";
+ mockGithub.paginate.mockResolvedValue([
+ { number: 1, title: "First issue", body: "Body one" },
+ { number: 2, title: "Second issue", body: "Body two" },
+ ]);
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ searches: [{ name: "issues", type: "issues", max: 10 }],
+ });
+
+ expect(mockGithub.paginate).toHaveBeenCalledOnce();
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ expect(mockStore.update).toHaveBeenCalledOnce();
+
+ // The script writes search results to /tmp/gh-aw/qmd-search-N (hardcoded in
+ // qmd_index.cjs, Linux-specific, mirrors the GitHub Actions runner).
+ const searchDir = "/tmp/gh-aw/qmd-search-0";
+ if (fs.existsSync(searchDir)) {
+ const files = fs.readdirSync(searchDir);
+ expect(files).toContain("issue-1.md");
+ expect(files).toContain("issue-2.md");
+ const content = fs.readFileSync(path.join(searchDir, "issue-1.md"), "utf8");
+ expect(content).toContain("## 1: First issue");
+ }
+ });
+
+ // ── Issues search: explicit repo field ───────────────────────────────────
+ it("uses explicit repo field instead of GITHUB_REPOSITORY for issues search", async () => {
+ process.env.GITHUB_REPOSITORY = "default/repo";
+ mockGithub.paginate.mockResolvedValue([]);
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ searches: [{ name: "issues", type: "issues", repo: "explicit/repo" }],
+ });
+
+ expect(mockGithub.paginate).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ owner: "explicit", repo: "repo" }), expect.any(Function));
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ });
+
+ // ── Issues search: invalid slug (no slash) ───────────────────────────────
+ it("fails when issues search repo slug has no slash", async () => {
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ searches: [{ name: "issues", type: "issues", repo: "invalid-no-slash" }],
+ });
+
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining('invalid repository slug "invalid-no-slash"'));
+ expect(mockStore.update).not.toHaveBeenCalled();
+ });
+
+ // ── Issues search: empty slug (GITHUB_REPOSITORY not set) ────────────────
+ it("fails when issues search slug is empty (GITHUB_REPOSITORY unset)", async () => {
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ searches: [{ name: "issues", type: "issues" }],
+ });
+
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("invalid repository slug"));
+ expect(mockStore.update).not.toHaveBeenCalled();
+ });
+
+ // ── Issues search: min count enforcement ─────────────────────────────────
+ it("fails when issues search returns fewer results than min", async () => {
+ process.env.GITHUB_REPOSITORY = "owner/repo";
+ mockGithub.paginate.mockResolvedValue([{ number: 1, title: "Only one", body: "" }]);
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ searches: [{ name: "issues", type: "issues", min: 5 }],
+ });
+
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("minimum is 5"));
+ expect(mockStore.update).not.toHaveBeenCalled();
+ });
+
+ // ── Code search: downloads files ──────────────────────────────────────────
+ it("downloads code search results and registers them as a collection", async () => {
+ mockGithub.rest.search.code.mockResolvedValue({
+ data: {
+ items: [
+ { path: "docs/README.md", repository: { full_name: "owner/repo" } },
+ { path: "docs/guide.md", repository: { full_name: "owner/repo" } },
+ ],
+ },
+ });
+ mockGithub.rest.repos.getContent.mockResolvedValue({
+ data: { type: "file", content: Buffer.from("# Content").toString("base64") },
+ });
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ searches: [{ name: "api-docs", query: "repo:owner/repo language:Markdown path:docs/", max: 10 }],
+ });
+
+ expect(mockGithub.rest.search.code).toHaveBeenCalledWith(expect.objectContaining({ q: "repo:owner/repo language:Markdown path:docs/" }));
+ expect(mockGithub.rest.repos.getContent).toHaveBeenCalledTimes(2);
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ expect(mockStore.update).toHaveBeenCalledOnce();
+
+ // /tmp/gh-aw/qmd-search-0 is the hardcoded search dir in qmd_index.cjs.
+ const searchDir = "/tmp/gh-aw/qmd-search-0";
+ if (fs.existsSync(searchDir)) {
+ const files = fs.readdirSync(searchDir);
+ expect(files.some(f => f.includes("owner-repo-docs-README.md"))).toBe(true);
+ expect(files.some(f => f.includes("owner-repo-docs-guide.md"))).toBe(true);
+ }
+ });
+
+ // ── Code search: min count enforcement ───────────────────────────────────
+ it("fails when code search returns fewer results than min", async () => {
+ mockGithub.rest.search.code.mockResolvedValue({ data: { items: [] } });
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ searches: [{ name: "docs", query: "repo:owner/repo", min: 3 }],
+ });
+
+ expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("minimum is 3"));
+ expect(mockStore.update).not.toHaveBeenCalled();
+ });
+
+ // ── Code search: download error is a warning, not a failure ──────────────
+ it("emits a warning (not failure) when getContent throws for a code search item", async () => {
+ mockGithub.rest.search.code.mockResolvedValue({
+ data: {
+ items: [{ path: "README.md", repository: { full_name: "owner/repo" } }],
+ },
+ });
+ mockGithub.rest.repos.getContent.mockRejectedValue(new Error("404 Not Found"));
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ searches: [{ name: "docs", query: "repo:owner/repo" }],
+ });
+
+ expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not download owner/repo/README.md"));
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ expect(mockStore.update).toHaveBeenCalledOnce();
+ });
+
+ // ── Code search: skip items with malformed full_name ─────────────────────
+ it("skips code search items whose repository full_name has no slash", async () => {
+ mockGithub.rest.search.code.mockResolvedValue({
+ data: {
+ items: [{ path: "file.md", repository: { full_name: "no-slash" } }],
+ },
+ });
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ searches: [{ name: "docs", query: "test" }],
+ });
+
+ expect(mockGithub.rest.repos.getContent).not.toHaveBeenCalled();
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ expect(mockStore.update).toHaveBeenCalledOnce();
+ });
+
+ // ── Combined: checkouts + searches ───────────────────────────────────────
+ it("combines checkout collections and search results into one index", async () => {
+ const docsDir = path.join(tmpDir, "docs");
+ fs.mkdirSync(docsDir);
+ fs.writeFileSync(path.join(docsDir, "readme.md"), "# README");
+
+ process.env.GITHUB_REPOSITORY = "owner/repo";
+ mockGithub.paginate.mockResolvedValue([{ number: 10, title: "Issue", body: "" }]);
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ checkouts: [{ name: "docs", path: docsDir, patterns: ["**/*.md"] }],
+ searches: [{ name: "issues", type: "issues", max: 50 }],
+ });
+
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ expect(mockStore.update).toHaveBeenCalledOnce();
+ expect(mockStore.embed).toHaveBeenCalledOnce();
+ expect(mockStore.close).toHaveBeenCalledOnce();
+ });
+
+ // ── finally: store.close() always called ─────────────────────────────────
+ it("always calls store.close() even when store.update() throws", async () => {
+ const docsDir = path.join(tmpDir, "docs");
+ fs.mkdirSync(docsDir);
+ mockStore.update.mockRejectedValue(new Error("update failed"));
+
+ await expect(
+ runMain({
+ dbPath: path.join(tmpDir, "index"),
+ checkouts: [{ name: "docs", path: docsDir }],
+ })
+ ).rejects.toThrow("update failed");
+
+ expect(mockStore.close).toHaveBeenCalledOnce();
+ });
+
+ // ── writeSummary: checkouts section ──────────────────────────────────────
+ it("writes step summary with a collections table for checkouts", async () => {
+ const docsDir = path.join(tmpDir, "docs");
+ fs.mkdirSync(docsDir);
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ checkouts: [{ name: "docs", path: docsDir, patterns: ["**/*.md", "**/*.mdx"], context: "Project docs" }],
+ });
+
+ const summaryText = mockCore.summary.addRaw.mock.calls.flat().join("\n");
+ expect(summaryText).toContain("### Collections");
+ expect(summaryText).toContain("| docs | **/*.md, **/*.mdx | Project docs |");
+ expect(mockCore.summary.write).toHaveBeenCalledOnce();
+ });
+
+ // ── writeSummary: searches section ───────────────────────────────────────
+ it("writes step summary with a searches table", async () => {
+ mockGithub.rest.search.code.mockResolvedValue({ data: { items: [] } });
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ searches: [{ name: "api-docs", query: "repo:owner/repo language:Markdown", min: 0, max: 20 }],
+ });
+
+ const summaryText = mockCore.summary.addRaw.mock.calls.flat().join("\n");
+ expect(summaryText).toContain("### Searches");
+ expect(summaryText).toContain("| api-docs | code | repo:owner/repo language:Markdown |");
+ });
+
+ // ── writeSummary: index stats section ────────────────────────────────────
+ it("writes step summary with update and embed statistics", async () => {
+ const docsDir = path.join(tmpDir, "docs");
+ fs.mkdirSync(docsDir);
+ mockStore.update.mockResolvedValue({ indexed: 7, updated: 2, unchanged: 1, removed: 0 });
+ mockStore.embed.mockResolvedValue({ embedded: 9 });
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ checkouts: [{ name: "docs", path: docsDir }],
+ });
+
+ const summaryText = mockCore.summary.addRaw.mock.calls.flat().join("\n");
+ expect(summaryText).toContain("### Index stats");
+ expect(summaryText).toContain("| Indexed | 7 |");
+ expect(summaryText).toContain("| Embedded | 9 |");
+ });
+
+ // ── writeSummary: error handling ─────────────────────────────────────────
+ it("emits a warning (not failure) when writing the step summary throws", async () => {
+ const docsDir = path.join(tmpDir, "docs");
+ fs.mkdirSync(docsDir);
+ mockCore.summary.write.mockRejectedValue(new Error("step summary unavailable"));
+
+ await runMain({
+ dbPath: path.join(tmpDir, "index"),
+ checkouts: [{ name: "docs", path: docsDir }],
+ });
+
+ expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not write step summary"));
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ });
+});
From ee504c239c63474f3ddeb9143e1c6253aaad0b4e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 04:49:44 +0000
Subject: [PATCH 16/49] fix: add @actions/github to qmd npm install step
The qmd_index.cjs script calls getOctokit() from @actions/github when a
custom tokenEnvVar is configured. This package must be installed alongside
@tobilu/qmd in the runner's local prefix directory so the require() resolves
at runtime.
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f757e6d9-5de3-4fab-bd7a-83fca641f0b3
---
.github/workflows/smoke-codex.lock.yml | 2 +-
pkg/workflow/qmd.go | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 6d8b19106b2..fa60e35c4c8 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -320,7 +320,7 @@ jobs:
node-version: "24"
- name: Install @tobilu/qmd SDK
run: |
- npm install --prefix "${{ runner.temp }}/gh-aw/actions" @tobilu/qmd@2.0.1
+ npm install --prefix "${{ runner.temp }}/gh-aw/actions" @tobilu/qmd@2.0.1 @actions/github
- name: Build qmd index
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 0b0b8014d26..208b5573a94 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -343,7 +343,7 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
npmInstall += ifCacheMiss
}
npmInstall += " run: |\n"
- npmInstall += fmt.Sprintf(" npm install --prefix \"${{ runner.temp }}/gh-aw/actions\" @tobilu/qmd@%s\n", version)
+ npmInstall += fmt.Sprintf(" npm install --prefix \"${{ runner.temp }}/gh-aw/actions\" @tobilu/qmd@%s @actions/github\n", version)
steps = append(steps, npmInstall)
// Emit a checkout step for each collection that targets a non-default repository
From b999aea923905b851d7ff99f7c3369c8776d0149 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 04:58:23 +0000
Subject: [PATCH 17/49] fix: use dynamic import() for @actions/github in
qmd_index.cjs
@actions/github is ESM-only and has no CJS exports main, so require()
throws ERR_PACKAGE_PATH_NOT_EXPORTED at runtime. Switch getClient() to
async and use await import("@actions/github") instead.
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4fab64ab-7cc4-4b1d-a12c-e85eb679cbe1
---
actions/setup/js/qmd_index.cjs | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/actions/setup/js/qmd_index.cjs b/actions/setup/js/qmd_index.cjs
index 333efb2e0c9..61b95a5d15f 100644
--- a/actions/setup/js/qmd_index.cjs
+++ b/actions/setup/js/qmd_index.cjs
@@ -24,11 +24,11 @@ function resolveEnvVars(p) {
/**
* Returns an Octokit client for the given token env var, or the default github client.
* @param {string | undefined} tokenEnvVar
- * @returns {typeof github}
+ * @returns {Promise}
*/
-function getClient(tokenEnvVar) {
+async function getClient(tokenEnvVar) {
if (tokenEnvVar && process.env[tokenEnvVar]) {
- const { getOctokit } = require("@actions/github");
+ const { getOctokit } = await import("@actions/github");
return getOctokit(process.env[tokenEnvVar]);
}
return github;
@@ -147,7 +147,7 @@ async function main() {
const searchDir = `/tmp/gh-aw/qmd-search-${i}`;
fs.mkdirSync(searchDir, { recursive: true });
- const client = getClient(search.tokenEnvVar);
+ const client = await getClient(search.tokenEnvVar);
if (search.type === "issues") {
const repoSlug = search.repo || process.env.GITHUB_REPOSITORY || "";
From bc6e3a6c05770525bc0bbcec6a7a27b04edf0ca1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 05:36:55 +0000
Subject: [PATCH 18/49] feat: move qmd indexing into a separate "indexing" job
- Add `IndexingJobName = "indexing"` constant
- New `buildQmdIndexingJob()` builds a standalone job that depends on
activation and runs all qmd index-building steps
- Remove qmd steps from the activation job
- Agent job now depends on both activation (for its outputs) and
indexing (for the qmd-index artifact)
- Fix artifact prefix expression in upload step: use
artifactPrefixExprForDownstreamJob (needs.activation.outputs.*) since
the upload now runs in the indexing job, not the activation job
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e25e8e38-e970-4485-87da-65776dd13b0d
---
.github/workflows/smoke-codex.lock.yml | 90 ++++++++++++++++---------
pkg/constants/constants.go | 1 +
pkg/workflow/compiler_activation_job.go | 9 +--
pkg/workflow/compiler_jobs.go | 23 +++++++
pkg/workflow/compiler_main_job.go | 9 +++
pkg/workflow/qmd.go | 90 ++++++++++++++++++++++---
6 files changed, 172 insertions(+), 50 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index fa60e35c4c8..0c8709e1340 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -309,38 +309,6 @@ jobs:
env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh
- - name: Cache qmd models
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
- with:
- path: ~/.cache/qmd/models/
- key: qmd-models-${{ runner.os }}
- - name: Setup Node.js for qmd
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- with:
- node-version: "24"
- - name: Install @tobilu/qmd SDK
- run: |
- npm install --prefix "${{ runner.temp }}/gh-aw/actions" @tobilu/qmd@2.0.1 @actions/github
- - name: Build qmd index
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- QMD_CONFIG_JSON: |
- {"dbPath":"/tmp/gh-aw/qmd-index","checkouts":[{"name":"docs","path":"${GITHUB_WORKSPACE}","patterns":["docs/src/**/*.md","docs/src/**/*.mdx"],"context":"gh-aw project documentation"}],"searches":[{"name":"issues","type":"issues","max":500,"tokenEnvVar":"QMD_SEARCH_TOKEN_0"}]}
- QMD_SEARCH_TOKEN_0: ${{ secrets.GITHUB_TOKEN }}
- with:
- github-token: ${{ github.token }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/qmd_index.cjs');
- await main();
- - name: Upload qmd index artifact
- if: success()
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- with:
- name: qmd-index
- path: /tmp/gh-aw/qmd-index/
- retention-days: 1
- name: Upload activation artifact
if: success()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
@@ -352,7 +320,9 @@ jobs:
retention-days: 1
agent:
- needs: activation
+ needs:
+ - activation
+ - indexing
runs-on: ubuntu-latest
permissions:
contents: read
@@ -1519,6 +1489,60 @@ jobs:
const { main } = require('${{ runner.temp }}/gh-aw/actions/notify_comment_error.cjs');
await main();
+ indexing:
+ needs: activation
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Checkout repository for qmd indexing
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - name: Cache qmd models
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/qmd/models/
+ key: qmd-models-${{ runner.os }}
+ - name: Setup Node.js for qmd
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: "24"
+ - name: Install @tobilu/qmd SDK
+ run: |
+ npm install --prefix "${{ runner.temp }}/gh-aw/actions" @tobilu/qmd@2.0.1 @actions/github
+ - name: Build qmd index
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ QMD_CONFIG_JSON: |
+ {"dbPath":"/tmp/gh-aw/qmd-index","checkouts":[{"name":"docs","path":"${GITHUB_WORKSPACE}","patterns":["docs/src/**/*.md","docs/src/**/*.mdx"],"context":"gh-aw project documentation"}],"searches":[{"name":"issues","type":"issues","max":500,"tokenEnvVar":"QMD_SEARCH_TOKEN_0"}]}
+ QMD_SEARCH_TOKEN_0: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ github-token: ${{ github.token }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/qmd_index.cjs');
+ await main();
+ - name: Upload qmd index artifact
+ if: success()
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
+ with:
+ name: qmd-index
+ path: /tmp/gh-aw/qmd-index/
+ retention-days: 1
+
pre_activation:
if: >
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id) &&
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 7d1fbf17158..9a7896362fe 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -615,6 +615,7 @@ var DangerousPropertyNames = []string{
const AgentJobName JobName = "agent"
const ActivationJobName JobName = "activation"
+const IndexingJobName JobName = "indexing"
const PreActivationJobName JobName = "pre_activation"
const DetectionJobName JobName = "detection"
const SafeOutputArtifactName = "safe-output"
diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go
index 7642f8fe828..d622e6cfd0f 100644
--- a/pkg/workflow/compiler_activation_job.go
+++ b/pkg/workflow/compiler_activation_job.go
@@ -486,13 +486,8 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
}
// Generate qmd index steps if the qmd tool is configured.
- // The index is built here in the activation job (which has contents:read) so the agent job
- // does not need contents:read permission to search documentation.
- if data.QmdConfig != nil {
- compilerActivationJobLog.Printf("Adding qmd index build steps: checkouts=%d searches=%d", len(data.QmdConfig.Checkouts), len(data.QmdConfig.Searches))
- qmdSteps := generateQmdIndexSteps(data.QmdConfig, data)
- steps = append(steps, qmdSteps...)
- }
+ // NOTE: qmd indexing is now handled by the separate "indexing" job that depends on activation.
+ // That job builds the index and uploads the qmd-index artifact so the agent job can download it.
// Upload aw_info.json and prompt.txt as the activation artifact for the agent job to download.
// In workflow_call context the artifact is prefixed to avoid name clashes when multiple callers
diff --git a/pkg/workflow/compiler_jobs.go b/pkg/workflow/compiler_jobs.go
index 160d0285b2f..56e154c8a7e 100644
--- a/pkg/workflow/compiler_jobs.go
+++ b/pkg/workflow/compiler_jobs.go
@@ -212,6 +212,15 @@ func (c *Compiler) buildJobs(data *WorkflowData, markdownPath string) error {
return err
}
+ // Build qmd indexing job if the qmd tool is configured.
+ // This separate job depends on activation and builds the documentation search index.
+ // The agent job then depends on this indexing job to download the pre-built index.
+ if data.QmdConfig != nil {
+ if err := c.buildQmdIndexingJobWrapper(data); err != nil {
+ return err
+ }
+ }
+
// Build main workflow job
if err := c.buildMainJobWrapper(data, activationJobCreated); err != nil {
return err
@@ -306,6 +315,20 @@ func (c *Compiler) buildMainJobWrapper(data *WorkflowData, activationJobCreated
return nil
}
+// buildQmdIndexingJobWrapper builds the qmd indexing job and adds it to the job manager.
+func (c *Compiler) buildQmdIndexingJobWrapper(data *WorkflowData) error {
+ compilerJobsLog.Print("Building qmd indexing job")
+ indexingJob, err := c.buildQmdIndexingJob(data)
+ if err != nil {
+ return fmt.Errorf("failed to build indexing job: %w", err)
+ }
+ if err := c.jobManager.AddJob(indexingJob); err != nil {
+ return fmt.Errorf("failed to add indexing job: %w", err)
+ }
+ compilerJobsLog.Printf("Successfully added indexing job: %s", string(constants.IndexingJobName))
+ return nil
+}
+
// buildMemoryManagementJobs builds memory management jobs (push_repo_memory and update_cache_memory).
// These jobs handle artifact-based memory persistence to git branches and GitHub Actions cache.
func (c *Compiler) buildMemoryManagementJobs(data *WorkflowData) error {
diff --git a/pkg/workflow/compiler_main_job.go b/pkg/workflow/compiler_main_job.go
index 68760e3a71d..b2189c04f6c 100644
--- a/pkg/workflow/compiler_main_job.go
+++ b/pkg/workflow/compiler_main_job.go
@@ -78,6 +78,15 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) (
depends = []string{string(constants.ActivationJobName)} // Depend on the activation job only if it exists
}
+ // When the qmd tool is configured, the agent also depends on the indexing job (which builds
+ // the qmd search index). The indexing job depends on activation, but GitHub Actions only
+ // exposes outputs from DIRECT dependencies, so we must keep activation in needs too so that
+ // needs.activation.outputs.* expressions resolve correctly.
+ if data.QmdConfig != nil {
+ depends = append(depends, string(constants.IndexingJobName))
+ compilerMainJobLog.Print("Agent job depends on indexing job (qmd tool configured)")
+ }
+
// Add custom jobs as dependencies only if they don't depend on pre_activation or agent
// Custom jobs that depend on pre_activation are now dependencies of activation,
// so the agent job gets them transitively through activation
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 208b5573a94..eab591ae44f 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -5,17 +5,20 @@
// This file handles the qmd (https://github.com/tobi/qmd) builtin tool integration.
// qmd provides local vector search over documentation files using the @tobilu/qmd npm package.
//
-// The integration has two phases:
+// The integration has three phases:
//
-// 1. Activation job: builds the search index from configured checkouts and/or GitHub searches
-// and uploads it as the "qmd-index" artifact. This step runs in the activation job which
-// already has contents:read permission, so the agent job does NOT need contents:read.
+// 1. Activation job: runs the normal activation steps (timestamp check, prompt, reactions, etc.).
+// Does NOT build the qmd index.
+//
+// 2. Indexing job (new): runs after activation, builds the search index from configured
+// checkouts and/or GitHub searches, and uploads it as the "qmd-index" artifact.
+// This job has contents:read permission so the agent job does NOT need it.
// The index is built by a single actions/github-script step that runs qmd_index.cjs,
// which uses the @tobilu/qmd JavaScript SDK to build the collections.
//
-// 2. Agent job: downloads the "qmd-index" artifact and mounts the qmd MCP server pointing
-// at the pre-built index. The MCP server exposes a search tool that the agent can use
-// to find relevant documentation files.
+// 3. Agent job: depends on BOTH the activation job (for its outputs) and the indexing job
+// (for the qmd-index artifact). Downloads the pre-built index and mounts the qmd MCP
+// server pointing at it.
//
// # Configuration
//
@@ -47,14 +50,14 @@
//
// # Artifact lifecycle
//
-// The index is built once per activation job run and shared with the agent job
+// The index is built once per indexing job run and shared with the agent job
// via the "qmd-index" artifact. Retention is 1 day (same as the activation artifact).
//
// Related files:
// - tools_types.go: QmdToolConfig, QmdDocCollection, QmdSearchEntry types
// - tools_parser.go: parseQmdTool / parseQmdDocCollection / parseQmdSearchEntry
// - mcp_renderer_builtin.go: RenderQmdMCP method
-// - compiler_activation_job.go: activation job qmd index steps
+// - compiler_jobs.go: buildQmdIndexingJobWrapper
// - compiler_yaml_main_job.go: agent job qmd artifact download
// - actions/setup/js/qmd_index.cjs: JavaScript SDK implementation
@@ -395,7 +398,9 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
// Upload qmd index as a separate artifact for the agent job
qmdLog.Print("Adding qmd index artifact upload step")
- qmdArtifactName := artifactPrefixExprForActivationJob(data) + constants.QmdArtifactName
+ // The upload runs in the indexing job (a downstream job from activation), so use the
+ // downstream prefix expression which references needs.activation.outputs.artifact_prefix.
+ qmdArtifactName := artifactPrefixExprForDownstreamJob(data) + constants.QmdArtifactName
steps = append(steps, " - name: Upload qmd index artifact\n")
steps = append(steps, " if: success()\n")
steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/upload-artifact")))
@@ -419,3 +424,68 @@ func generateQmdDownloadStep(data *WorkflowData) string {
sb.WriteString(" path: /tmp/gh-aw/qmd-index/\n")
return sb.String()
}
+
+// buildQmdIndexingJob builds a standalone "indexing" job that depends on the activation job
+// and builds the qmd documentation search index.
+//
+// The job:
+// 1. Checks out the actions folder (for the setup action scripts)
+// 2. Runs the setup action to copy qmd_index.cjs and setup_globals.cjs to the runner
+// 3. Optionally checks out the workspace for checkout-based collections
+// 4. Installs @tobilu/qmd and @actions/github and runs qmd_index.cjs via actions/github-script
+// 5. Uploads the resulting index as the qmd-index artifact
+//
+// The agent job declares a needs dependency on this "indexing" job and downloads the artifact.
+func (c *Compiler) buildQmdIndexingJob(data *WorkflowData) (*Job, error) {
+ qmdLog.Printf("Building qmd indexing job: checkouts=%d searches=%d cacheKey=%q",
+ len(data.QmdConfig.Checkouts), len(data.QmdConfig.Searches), data.QmdConfig.CacheKey)
+
+ var steps []string
+
+ // Check out the actions folder so the setup action scripts are available on the runner.
+ steps = append(steps, c.generateCheckoutActionsFolder(data)...)
+
+ // Run the setup action to copy qmd_index.cjs and setup_globals.cjs to SetupActionDestination.
+ setupActionRef := c.resolveActionReference("./actions/setup", data)
+ steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...)
+
+ // Check out the repository workspace if any checkout-based collection uses the default repo
+ // (i.e., no per-collection checkout config, meaning it relies on ${GITHUB_WORKSPACE}).
+ needsWorkspaceCheckout := false
+ for _, col := range data.QmdConfig.Checkouts {
+ if col.Checkout == nil {
+ needsWorkspaceCheckout = true
+ break
+ }
+ }
+ if needsWorkspaceCheckout {
+ var sb strings.Builder
+ sb.WriteString(" - name: Checkout repository for qmd indexing\n")
+ fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/checkout"))
+ sb.WriteString(" with:\n")
+ sb.WriteString(" persist-credentials: false\n")
+ steps = append(steps, sb.String())
+ }
+
+ // Generate all qmd index-building steps (cache restore/save, Node.js, SDK install, github-script, artifact upload).
+ qmdSteps := generateQmdIndexSteps(data.QmdConfig, data)
+ steps = append(steps, qmdSteps...)
+
+ // The indexing job runs after the activation job to inherit the artifact prefix output.
+ needs := []string{string(constants.ActivationJobName)}
+
+ // Permissions: contents:read is required to checkout files for index building.
+ perms := NewPermissionsFromMap(map[PermissionScope]PermissionLevel{
+ PermissionContents: PermissionRead,
+ })
+
+ job := &Job{
+ Name: string(constants.IndexingJobName),
+ RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs),
+ Permissions: perms.RenderToYAML(),
+ Steps: steps,
+ Needs: needs,
+ }
+
+ return job, nil
+}
From ab98791c46c15179d82fdaeeab04afee42b20317 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 05:54:00 +0000
Subject: [PATCH 19/49] feat: always cache qmd index and add 60min timeout to
indexing job
- Add resolveQmdCacheKey() to return user cache-key or default
gh-aw-qmd-${{ github.run_id }} for per-run ephemeral caching
- Always emit cache restore/save steps in indexing job (previously only
when cache-key was explicitly set)
- Add generateQmdIndexCacheRestoreStep() for agent job restore (read-only)
- Add timeout-minutes: 60 to the indexing job
- Build steps are still guarded by cache-hit condition so they are
skipped when a previous run already populated the cache
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4b67c8f9-0127-41de-9682-0be1a2dcb7f3
---
.github/workflows/smoke-codex.lock.yml | 21 ++++++
pkg/workflow/compiler_yaml_main_job.go | 6 +-
pkg/workflow/qmd.go | 95 +++++++++++++++-----------
3 files changed, 82 insertions(+), 40 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 0c8709e1340..47e99666b4a 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -428,6 +428,11 @@ jobs:
with:
name: qmd-index
path: /tmp/gh-aw/qmd-index/
+ - name: Restore qmd index from cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
- name: Restore qmd models cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
@@ -1494,6 +1499,7 @@ jobs:
runs-on: ubuntu-slim
permissions:
contents: read
+ timeout-minutes: 60
steps:
- name: Checkout actions folder
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -1510,19 +1516,28 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
+ - name: Restore qmd index from cache
+ id: qmd-cache-restore
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
- name: Cache qmd models
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.cache/qmd/models/
key: qmd-models-${{ runner.os }}
- name: Setup Node.js for qmd
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "24"
- name: Install @tobilu/qmd SDK
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
run: |
npm install --prefix "${{ runner.temp }}/gh-aw/actions" @tobilu/qmd@2.0.1 @actions/github
- name: Build qmd index
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
QMD_CONFIG_JSON: |
@@ -1535,6 +1550,12 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/qmd_index.cjs');
await main();
+ - name: Save qmd index to cache
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
- name: Upload qmd index artifact
if: success()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index 81193643ab3..a0f73f82e3a 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -280,10 +280,14 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
}
// Download qmd index artifact if qmd tool is configured.
- // The index was built in the activation job to avoid needing contents:read in the agent job.
+ // The index was built in the indexing job (which has contents:read).
+ // In addition to the artifact, the agent also restores from the Actions cache so that
+ // the index is available even if the artifact has expired.
if data.QmdConfig != nil {
compilerYamlLog.Print("Adding qmd index download step")
yaml.WriteString(generateQmdDownloadStep(data))
+ compilerYamlLog.Print("Adding qmd index cache restore step (read-only)")
+ yaml.WriteString(generateQmdIndexCacheRestoreStep(data.QmdConfig))
compilerYamlLog.Print("Adding qmd models cache restore step (read-only)")
yaml.WriteString(generateQmdModelsCacheRestoreStep())
}
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index eab591ae44f..a3b4912c147 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -107,7 +107,7 @@ func generateQmdModelsCacheStep() string {
// generateQmdModelsCacheRestoreStep generates a read-only step that restores the qmd embedding
// models directory (~/.cache/qmd/models/) from GitHub Actions cache. It uses
// actions/cache/restore (restore-only, no post-save) so the agent job never writes to the
-// shared cache — that is the activation job's responsibility.
+// shared cache — that is the indexing job's responsibility.
func generateQmdModelsCacheRestoreStep() string {
var sb strings.Builder
sb.WriteString(" - name: Restore qmd models cache\n")
@@ -118,9 +118,34 @@ func generateQmdModelsCacheRestoreStep() string {
return sb.String()
}
-// generateQmdCacheRestoreStep generates an activation-job step that restores the qmd index
-// from GitHub Actions cache. The step ID is "qmd-cache-restore" so that subsequent steps
-// can check cache-hit via steps.qmd-cache-restore.outputs.cache-hit.
+// generateQmdIndexCacheRestoreStep generates a read-only restore step for the agent job that
+// restores the qmd search index from the Actions cache. It uses the same resolved cache key
+// as the indexing job so that the index is available even if the artifact has already expired.
+func generateQmdIndexCacheRestoreStep(qmdConfig *QmdToolConfig) string {
+ cacheKey := resolveQmdCacheKey(qmdConfig)
+ var sb strings.Builder
+ sb.WriteString(" - name: Restore qmd index from cache\n")
+ fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/restore"))
+ sb.WriteString(" with:\n")
+ fmt.Fprintf(&sb, " key: %s\n", cacheKey)
+ sb.WriteString(" path: /tmp/gh-aw/qmd-index/\n")
+ return sb.String()
+}
+
+// resolveQmdCacheKey returns the effective cache key for the qmd index.
+// If the user specified an explicit cache-key, that is returned as-is.
+// Otherwise a per-run key is generated using the GitHub workflow run ID so that
+// the index built in the indexing job is always persisted to cache and the agent
+// job can restore it without needing a separate artifact download on every run.
+//
+// The default key format is: gh-aw-qmd-
+// (e.g. "gh-aw-qmd-12345678")
+func resolveQmdCacheKey(qmdConfig *QmdToolConfig) string {
+ if qmdConfig.CacheKey != "" {
+ return qmdConfig.CacheKey
+ }
+ return "gh-aw-qmd-${{ github.run_id }}"
+}
func generateQmdCacheRestoreStep(cacheKey string) string {
var sb strings.Builder
sb.WriteString(" - name: Restore qmd index from cache\n")
@@ -285,7 +310,7 @@ func generateQmdCollectionCheckoutStep(col *QmdDocCollection) string {
return sb.String()
}
-// generateQmdIndexSteps generates the activation job steps that install the @tobilu/qmd SDK,
+// generateQmdIndexSteps generates the indexing job steps that install the @tobilu/qmd SDK,
// run the qmd_index.cjs JavaScript script to build the vector search index, and upload it
// as the qmd-index artifact.
//
@@ -295,26 +320,28 @@ func generateQmdCollectionCheckoutStep(col *QmdDocCollection) string {
// 2. Fetch GitHub search/issue results and register them as collections
// 3. Call store.update() and store.embed() to index and embed all documents
//
-// When qmdConfig.CacheKey is set:
-// - A cache restore step is always emitted first.
-// - In read-only mode (no sources): only the cache restore + artifact upload are emitted;
-// Node.js, qmd SDK installation, and indexing steps are skipped entirely.
-// - In build mode (sources present): indexing steps are guarded by
+// A cache restore step is always emitted first using the resolved cache key (user-provided
+// or the default per-run key gh-aw-qmd-${{ github.run_id }}). When qmdConfig.CacheKey is
+// not set, the default run-scoped key means the cache is ephemeral (only used within a
+// single workflow run). When qmdConfig.CacheKey IS set, the cache is durable across runs.
+//
+// Modes:
+// - Read-only mode (cache-key set, no sources): only cache restore + artifact upload.
+// - Build mode (sources present): indexing steps are guarded by
// `if: steps.qmd-cache-restore.outputs.cache-hit != 'true'`, so they are skipped on a
-// cache hit. A cache save step follows the indexing steps.
+// cache hit. A cache save step always follows.
func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []string {
hasSources := qmdHasSources(qmdConfig)
isCacheOnlyMode := qmdConfig.CacheKey != "" && !hasSources
+ cacheKey := resolveQmdCacheKey(qmdConfig)
qmdLog.Printf("Generating qmd index steps: checkouts=%d searches=%d cacheKey=%q cacheOnly=%v",
- len(qmdConfig.Checkouts), len(qmdConfig.Searches), qmdConfig.CacheKey, isCacheOnlyMode)
+ len(qmdConfig.Checkouts), len(qmdConfig.Searches), cacheKey, isCacheOnlyMode)
version := string(constants.DefaultQmdVersion)
var steps []string
- // If a cache-key is set, always restore first (both cache-only and build modes)
- if qmdConfig.CacheKey != "" {
- steps = append(steps, generateQmdCacheRestoreStep(qmdConfig.CacheKey))
- }
+ // Always restore from cache first; the step ID lets subsequent steps detect cache-hit.
+ steps = append(steps, generateQmdCacheRestoreStep(cacheKey))
// Always cache qmd embedding models to avoid re-downloading on each run
steps = append(steps, generateQmdModelsCacheStep())
@@ -323,17 +350,12 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
if isCacheOnlyMode {
qmdLog.Print("qmd cache-only mode: skipping indexing, using cache only")
} else {
- // Conditional prefix for build steps when cache-key is set (skip on cache hit)
- ifCacheMiss := ""
- if qmdConfig.CacheKey != "" {
- ifCacheMiss = " if: steps.qmd-cache-restore.outputs.cache-hit != 'true'\n"
- }
+ // Build steps are skipped when the cache was already populated on a previous run.
+ ifCacheMiss := " if: steps.qmd-cache-restore.outputs.cache-hit != 'true'\n"
// Setup Node.js (required to run the qmd SDK)
nodeSetup := " - name: Setup Node.js for qmd\n"
- if ifCacheMiss != "" {
- nodeSetup += ifCacheMiss
- }
+ nodeSetup += ifCacheMiss
nodeSetup += fmt.Sprintf(" uses: %s\n", GetActionPin("actions/setup-node"))
nodeSetup += " with:\n"
nodeSetup += fmt.Sprintf(" node-version: \"%s\"\n", string(constants.DefaultNodeVersion))
@@ -342,9 +364,7 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
// Install the @tobilu/qmd SDK into the gh-aw actions directory so qmd_index.cjs
// can require('@tobilu/qmd') via the adjacent node_modules folder.
npmInstall := " - name: Install @tobilu/qmd SDK\n"
- if ifCacheMiss != "" {
- npmInstall += ifCacheMiss
- }
+ npmInstall += ifCacheMiss
npmInstall += " run: |\n"
npmInstall += fmt.Sprintf(" npm install --prefix \"${{ runner.temp }}/gh-aw/actions\" @tobilu/qmd@%s @actions/github\n", version)
steps = append(steps, npmInstall)
@@ -367,9 +387,7 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
// Generate the github-script step that runs qmd_index.cjs
var scriptSB strings.Builder
scriptSB.WriteString(" - name: Build qmd index\n")
- if ifCacheMiss != "" {
- scriptSB.WriteString(ifCacheMiss)
- }
+ scriptSB.WriteString(ifCacheMiss)
fmt.Fprintf(&scriptSB, " uses: %s\n", GetActionPin("actions/github-script"))
scriptSB.WriteString(" env:\n")
// Pass the config JSON as an env var; the YAML literal block avoids quoting issues
@@ -390,10 +408,8 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
scriptSB.WriteString(" await main();\n")
steps = append(steps, scriptSB.String())
- // If cache-key is set, save the freshly-built index to cache (skipped on hit)
- if qmdConfig.CacheKey != "" {
- steps = append(steps, generateQmdCacheSaveStep(qmdConfig.CacheKey))
- }
+ // Always save to cache (on build; skipped on cache hit by the save step condition).
+ steps = append(steps, generateQmdCacheSaveStep(cacheKey))
}
// Upload qmd index as a separate artifact for the agent job
@@ -480,11 +496,12 @@ func (c *Compiler) buildQmdIndexingJob(data *WorkflowData) (*Job, error) {
})
job := &Job{
- Name: string(constants.IndexingJobName),
- RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs),
- Permissions: perms.RenderToYAML(),
- Steps: steps,
- Needs: needs,
+ Name: string(constants.IndexingJobName),
+ RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs),
+ Permissions: perms.RenderToYAML(),
+ Steps: steps,
+ Needs: needs,
+ TimeoutMinutes: 60, // building the qmd index can take a while for large doc sets
}
return job, nil
From 190c8fe0528428d464b7b3eb61b27f3c8d7698dd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 06:23:48 +0000
Subject: [PATCH 20/49] feat: add restore-keys to qmd index cache steps for
cross-run reuse
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add resolveQmdRestoreKeys() that strips the trailing ${{ ... }} from
the cache key to produce a prefix restore key:
- "gh-aw-qmd-${{ github.run_id }}" → restore-keys: "gh-aw-qmd-"
- "qmd-index-${{ hashFiles('docs/**') }}" → restore-keys: "qmd-index-"
Both the indexing job (generateQmdCacheRestoreStep) and the agent job
(generateQmdIndexCacheRestoreStep) now emit restore-keys so a workflow
can restore the most recently cached index from a previous run even
when the exact run-scoped key is not found, and update it if needed.
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ea6cdefe-d23e-44fc-aaba-668b7df94c17
---
.github/workflows/smoke-codex.lock.yml | 4 +++
pkg/workflow/qmd.go | 41 ++++++++++++++++++++++++--
2 files changed, 43 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 47e99666b4a..7637c2f6d30 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -433,6 +433,8 @@ jobs:
with:
key: gh-aw-qmd-${{ github.run_id }}
path: /tmp/gh-aw/qmd-index/
+ restore-keys: |
+ gh-aw-qmd-
- name: Restore qmd models cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
@@ -1522,6 +1524,8 @@ jobs:
with:
key: gh-aw-qmd-${{ github.run_id }}
path: /tmp/gh-aw/qmd-index/
+ restore-keys: |
+ gh-aw-qmd-
- name: Cache qmd models
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index a3b4912c147..98d97a32466 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -121,14 +121,22 @@ func generateQmdModelsCacheRestoreStep() string {
// generateQmdIndexCacheRestoreStep generates a read-only restore step for the agent job that
// restores the qmd search index from the Actions cache. It uses the same resolved cache key
// as the indexing job so that the index is available even if the artifact has already expired.
+// restore-keys are included so the agent can fall back to a cached index from a previous run.
func generateQmdIndexCacheRestoreStep(qmdConfig *QmdToolConfig) string {
cacheKey := resolveQmdCacheKey(qmdConfig)
+ restoreKeys := resolveQmdRestoreKeys(qmdConfig)
var sb strings.Builder
sb.WriteString(" - name: Restore qmd index from cache\n")
fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/restore"))
sb.WriteString(" with:\n")
fmt.Fprintf(&sb, " key: %s\n", cacheKey)
sb.WriteString(" path: /tmp/gh-aw/qmd-index/\n")
+ if len(restoreKeys) > 0 {
+ sb.WriteString(" restore-keys: |\n")
+ for _, rk := range restoreKeys {
+ fmt.Fprintf(&sb, " %s\n", rk)
+ }
+ }
return sb.String()
}
@@ -146,7 +154,30 @@ func resolveQmdCacheKey(qmdConfig *QmdToolConfig) string {
}
return "gh-aw-qmd-${{ github.run_id }}"
}
-func generateQmdCacheRestoreStep(cacheKey string) string {
+
+// resolveQmdRestoreKeys returns the restore-keys prefix list for the qmd index cache.
+// The restore keys allow a workflow run to reuse the most recently cached index
+// (from a previous run) even when the exact key is not found, so the index can
+// be updated incrementally rather than built from scratch every time.
+//
+// The prefix is derived by stripping the last ${{ ... }} expression from the cache key:
+//
+// "gh-aw-qmd-${{ github.run_id }}" → ["gh-aw-qmd-"]
+// "qmd-index-${{ hashFiles('docs/**') }}" → ["qmd-index-"]
+//
+// When the key contains no expression suffix, no restore-keys are emitted.
+func resolveQmdRestoreKeys(qmdConfig *QmdToolConfig) []string {
+ key := resolveQmdCacheKey(qmdConfig)
+ idx := strings.LastIndex(key, "${{")
+ if idx > 0 {
+ return []string{key[:idx]}
+ }
+ return nil
+}
+
+func generateQmdCacheRestoreStep(qmdConfig *QmdToolConfig) string {
+ cacheKey := resolveQmdCacheKey(qmdConfig)
+ restoreKeys := resolveQmdRestoreKeys(qmdConfig)
var sb strings.Builder
sb.WriteString(" - name: Restore qmd index from cache\n")
sb.WriteString(" id: qmd-cache-restore\n")
@@ -154,6 +185,12 @@ func generateQmdCacheRestoreStep(cacheKey string) string {
sb.WriteString(" with:\n")
fmt.Fprintf(&sb, " key: %s\n", cacheKey)
sb.WriteString(" path: /tmp/gh-aw/qmd-index/\n")
+ if len(restoreKeys) > 0 {
+ sb.WriteString(" restore-keys: |\n")
+ for _, rk := range restoreKeys {
+ fmt.Fprintf(&sb, " %s\n", rk)
+ }
+ }
return sb.String()
}
@@ -341,7 +378,7 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
var steps []string
// Always restore from cache first; the step ID lets subsequent steps detect cache-hit.
- steps = append(steps, generateQmdCacheRestoreStep(cacheKey))
+ steps = append(steps, generateQmdCacheRestoreStep(qmdConfig))
// Always cache qmd embedding models to avoid re-downloading on each run
steps = append(steps, generateQmdModelsCacheStep())
From a733a53ce39f56aef47d303d62afcd5258f9aadc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 08:44:24 +0000
Subject: [PATCH 21/49] feat: add gpu and runs-on fields to QmdToolConfig
- GPU defaults to false: injects NODE_LLAMA_CPP_GPU="false" into the
Build qmd index step so node-llama-cpp skips GPU probing on CPU runners
- Set gpu: true in workflow frontmatter to re-enable GPU auto-detection
- runs-on overrides the indexing job's runner image (e.g. "ubuntu-latest-gpu"
or "self-hosted") independently of the agent job runner
- JSON schema updated with gpu and runs-on properties for editor completion
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b2bc7a8c-f3fc-4aad-ab35-84de655a9519
---
.github/workflows/smoke-codex.lock.yml | 1 +
pkg/parser/schemas/main_workflow_schema.json | 10 ++++++++++
pkg/workflow/qmd.go | 14 +++++++++++++-
pkg/workflow/tools_parser.go | 18 ++++++++++++++++++
pkg/workflow/tools_types.go | 11 +++++++++++
5 files changed, 53 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 7637c2f6d30..83f1207c1ea 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -1546,6 +1546,7 @@ jobs:
env:
QMD_CONFIG_JSON: |
{"dbPath":"/tmp/gh-aw/qmd-index","checkouts":[{"name":"docs","path":"${GITHUB_WORKSPACE}","patterns":["docs/src/**/*.md","docs/src/**/*.mdx"],"context":"gh-aw project documentation"}],"searches":[{"name":"issues","type":"issues","max":500,"tokenEnvVar":"QMD_SEARCH_TOKEN_0"}]}
+ NODE_LLAMA_CPP_GPU: "false"
QMD_SEARCH_TOKEN_0: ${{ secrets.GITHUB_TOKEN }}
with:
github-token: ${{ github.token }}
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index d3adeb50668..b49f0196a47 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -3383,6 +3383,16 @@
"type": "string",
"description": "GitHub Actions cache key used to persist the qmd index across workflow runs. When set without any indexing sources (checkouts/searches), qmd operates in read-only mode: the index is restored from cache and all indexing steps are skipped.",
"examples": ["qmd-index-${{ hashFiles('docs/**') }}", "qmd-index-v1"]
+ },
+ "gpu": {
+ "type": "boolean",
+ "description": "Enable GPU acceleration for the embedding model (node-llama-cpp). Defaults to false: NODE_LLAMA_CPP_GPU=false is injected into the indexing step so GPU probing is skipped on CPU-only runners. Set to true only when the indexing runner has a GPU.",
+ "default": false
+ },
+ "runs-on": {
+ "type": "string",
+ "description": "Override the runner image for the qmd indexing job. Defaults to the same runner as the agent job. Use this when the indexing job requires a different runner (e.g. a GPU runner).",
+ "examples": ["ubuntu-latest", "ubuntu-latest-gpu", "self-hosted"]
}
},
"additionalProperties": false,
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 98d97a32466..7621db795ba 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -430,6 +430,11 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
// Pass the config JSON as an env var; the YAML literal block avoids quoting issues
scriptSB.WriteString(" QMD_CONFIG_JSON: |\n")
fmt.Fprintf(&scriptSB, " %s\n", string(cfgJSON))
+ // Disable GPU acceleration by default; only enable when the user explicitly opts in.
+ // This prevents node-llama-cpp from spending time probing GPU drivers on CPU runners.
+ if !qmdConfig.GPU {
+ scriptSB.WriteString(" NODE_LLAMA_CPP_GPU: \"false\"\n")
+ }
// Add per-search custom token env vars
for i, s := range qmdConfig.Searches {
if s.GitHubToken != "" {
@@ -532,9 +537,16 @@ func (c *Compiler) buildQmdIndexingJob(data *WorkflowData) (*Job, error) {
PermissionContents: PermissionRead,
})
+ // Determine the runner for the indexing job.
+ // The user can override via qmd.runs-on; otherwise fall back to the safe-outputs runner.
+ indexingRunsOn := c.formatSafeOutputsRunsOn(data.SafeOutputs)
+ if data.QmdConfig.RunsOn != "" {
+ indexingRunsOn = "runs-on: " + data.QmdConfig.RunsOn
+ }
+
job := &Job{
Name: string(constants.IndexingJobName),
- RunsOn: c.formatSafeOutputsRunsOn(data.SafeOutputs),
+ RunsOn: indexingRunsOn,
Permissions: perms.RenderToYAML(),
Steps: steps,
Needs: needs,
diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go
index 472a154ffa1..2412117447a 100644
--- a/pkg/workflow/tools_parser.go
+++ b/pkg/workflow/tools_parser.go
@@ -339,6 +339,8 @@ func parsePlaywrightTool(val any) *PlaywrightToolConfig {
// - checkouts: list of named collections (with optional checkout per entry)
// - searches: list of GitHub search queries
// - cache-key: optional GitHub Actions cache key
+// - gpu: enable GPU acceleration for node-llama-cpp (default: false)
+// - runs-on: override runner image for the indexing job
func parseQmdTool(val any) *QmdToolConfig {
if val == nil {
toolsParserLog.Print("qmd tool enabled with empty configuration")
@@ -354,6 +356,22 @@ func parseQmdTool(val any) *QmdToolConfig {
toolsParserLog.Printf("qmd tool cache-key: %s", cacheKey)
}
+ // Handle gpu field (defaults to false — GPU disabled by default)
+ if gpuVal, exists := configMap["gpu"]; exists {
+ if gpuBool, ok := gpuVal.(bool); ok {
+ config.GPU = gpuBool
+ toolsParserLog.Printf("qmd tool gpu: %v", gpuBool)
+ }
+ }
+
+ // Handle runs-on field (override runner image for the indexing job)
+ if runsOnVal, exists := configMap["runs-on"]; exists {
+ if runsOnStr, ok := runsOnVal.(string); ok && runsOnStr != "" {
+ config.RunsOn = runsOnStr
+ toolsParserLog.Printf("qmd tool runs-on: %s", runsOnStr)
+ }
+ }
+
// Handle checkouts field
if checkoutsValue, ok := configMap["checkouts"]; ok {
if arr, ok := checkoutsValue.([]any); ok {
diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go
index 14f91724814..6a134cda44f 100644
--- a/pkg/workflow/tools_types.go
+++ b/pkg/workflow/tools_types.go
@@ -403,6 +403,17 @@ type QmdToolConfig struct {
// from cache without any indexing steps.
// Example: "qmd-index-${{ hashFiles('docs/**') }}"
CacheKey string `yaml:"cache-key,omitempty"`
+
+ // GPU controls whether node-llama-cpp (used by @tobilu/qmd internally) may use
+ // GPU acceleration. Defaults to false: NODE_LLAMA_CPP_GPU=false is injected into
+ // the indexing step so that GPU auto-detection is skipped on CPU-only runners.
+ // Set to true only when the indexing runner has a GPU.
+ GPU bool `yaml:"gpu,omitempty"`
+
+ // RunsOn overrides the runner image for the indexing job.
+ // Defaults to the same runner as the agent job (ubuntu-latest or as configured).
+ // Example: "ubuntu-latest-gpu" or ["self-hosted", "gpu"]
+ RunsOn string `yaml:"runs-on,omitempty"`
}
// SerenaToolConfig represents the configuration for the Serena MCP tool
From f556ca8b41a6c57f970422b1755d7cd39c8e6776 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 09:14:10 +0000
Subject: [PATCH 22/49] feat: add qmd MCP HTTP server start step with health
probe in agent job
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/92e6bea6-e7ac-4e81-9dc2-4f72aaed1db5
---
.github/workflows/smoke-codex.lock.yml | 36 ++++----
actions/setup/sh/start_qmd_server.sh | 109 +++++++++++++++++++++++++
pkg/constants/constants.go | 3 +
pkg/workflow/compiler_yaml_main_job.go | 2 +
pkg/workflow/mcp_environment.go | 6 ++
pkg/workflow/mcp_renderer_builtin.go | 68 +++------------
pkg/workflow/qmd.go | 28 +++++++
7 files changed, 178 insertions(+), 74 deletions(-)
create mode 100644 actions/setup/sh/start_qmd_server.sh
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 83f1207c1ea..b57d4063027 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -440,6 +440,17 @@ jobs:
with:
path: ~/.cache/qmd/models/
key: qmd-models-${{ runner.os }}
+ - name: Start qmd MCP HTTP server
+ id: qmd-server-start
+ env:
+ GH_AW_QMD_PORT: "3002"
+ QMD_CACHE_DIR: /tmp/gh-aw/qmd-index
+ NODE_LLAMA_CPP_GPU: "false"
+ run: |
+ export GH_AW_QMD_PORT
+ export QMD_CACHE_DIR
+ export NODE_LLAMA_CPP_GPU
+ bash ${RUNNER_TEMP}/gh-aw/actions/start_qmd_server.sh
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -839,6 +850,7 @@ jobs:
GH_AW_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_AW_MCP_SCRIPTS_API_KEY: ${{ steps.mcp-scripts-start.outputs.api_key }}
GH_AW_MCP_SCRIPTS_PORT: ${{ steps.mcp-scripts-start.outputs.port }}
+ GH_AW_QMD_PORT: ${{ steps.qmd-server-start.outputs.port }}
GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
@@ -863,7 +875,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="codex"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_DEBUG -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.20'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_AW_QMD_PORT -e GH_DEBUG -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.20'
cat > /tmp/gh-aw/mcp-config/config.toml << GH_AW_MCP_CONFIG_EOF
[history]
@@ -913,14 +925,8 @@ jobs:
accept = ["*"]
[mcp_servers.qmd]
- container = "node:lts-alpine"
- entrypoint = "npx"
- entrypointArgs = [
- "@tobilu/qmd@2.0.1",
- "serve-mcp",
- ]
- mounts = ["/tmp/gh-aw/qmd-index:/tmp/gh-aw/qmd-index:ro"]
- env_vars = ["QMD_CACHE_DIR"]
+ type = "http"
+ url = "http://host.docker.internal:$GH_AW_QMD_PORT"
[mcp_servers.safeoutputs]
type = "http"
@@ -1022,16 +1028,8 @@ jobs:
}
},
"qmd": {
- "container": "node:lts-alpine",
- "entrypoint": "npx",
- "entrypointArgs": [
- "@tobilu/qmd@2.0.1",
- "serve-mcp"
- ],
- "mounts": ["/tmp/gh-aw/qmd-index:/tmp/gh-aw/qmd-index:ro"],
- "env": {
- "QMD_CACHE_DIR": "/tmp/gh-aw/qmd-index"
- }
+ "type": "http",
+ "url": "http://host.docker.internal:$GH_AW_QMD_PORT"
},
"safeoutputs": {
"type": "http",
diff --git a/actions/setup/sh/start_qmd_server.sh b/actions/setup/sh/start_qmd_server.sh
new file mode 100644
index 00000000000..b2d35b6f4a0
--- /dev/null
+++ b/actions/setup/sh/start_qmd_server.sh
@@ -0,0 +1,109 @@
+#!/usr/bin/env bash
+# Start qmd MCP HTTP Server
+# This script starts the qmd MCP server with HTTP transport and waits for it to become ready.
+#
+# qmd uses node-llama-cpp to run embedding models. On first use the llama.cpp binary must be
+# downloaded, which can take several minutes. The health probe loop below accounts for this
+# by waiting up to 10 minutes before giving up.
+#
+# Required environment variables:
+# GH_AW_QMD_PORT - Port to listen on (e.g. 3002)
+# QMD_CACHE_DIR - Path to the pre-built qmd index (e.g. /tmp/gh-aw/qmd-index)
+#
+# Optional environment variables:
+# NODE_LLAMA_CPP_GPU - Set to "false" to disable GPU probing (default: "false")
+
+set -e
+
+echo "Starting qmd MCP HTTP server..."
+echo " Port: ${GH_AW_QMD_PORT}"
+echo " Cache dir: ${QMD_CACHE_DIR}"
+echo " GPU: ${NODE_LLAMA_CPP_GPU:-auto}"
+
+# Ensure logs directory exists
+mkdir -p /tmp/gh-aw/mcp-logs/qmd
+
+# Create initial log file for artifact upload
+{
+ echo "qmd MCP HTTP Server Log"
+ echo "Start time: $(date)"
+ echo "==========================================="
+ echo ""
+} > /tmp/gh-aw/mcp-logs/qmd/server.log
+
+# Start the qmd MCP server with HTTP transport in the background.
+# QMD_CACHE_DIR tells qmd where the pre-built vector index lives.
+# NODE_LLAMA_CPP_GPU controls GPU probing; "false" disables it on CPU runners.
+QMD_CACHE_DIR="${QMD_CACHE_DIR}" \
+NODE_LLAMA_CPP_GPU="${NODE_LLAMA_CPP_GPU:-false}" \
+ npx "@tobilu/qmd" serve-mcp --http --port "${GH_AW_QMD_PORT}" \
+ >> /tmp/gh-aw/mcp-logs/qmd/server.log 2>&1 &
+
+SERVER_PID=$!
+echo "Started qmd MCP server with PID ${SERVER_PID}"
+
+# Wait for the server to become ready.
+# A long timeout (10 minutes = 600 seconds) is used because node-llama-cpp may download
+# llama.cpp binaries and embedding model weights on the first run.
+TIMEOUT_SECONDS=600
+RETRY_DELAY=1
+MAX_ATTEMPTS=$((TIMEOUT_SECONDS / RETRY_DELAY))
+ATTEMPT=0
+HEALTH_START=$(date +%s%3N)
+
+echo "Waiting for qmd MCP server to become ready (timeout: ${TIMEOUT_SECONDS}s)..."
+
+while [ "${ATTEMPT}" -lt "${MAX_ATTEMPTS}" ]; do
+ ATTEMPT=$((ATTEMPT + 1))
+
+ # Abort if the server process has already exited
+ if ! kill -0 "${SERVER_PID}" 2>/dev/null; then
+ echo "ERROR: qmd MCP server process (PID ${SERVER_PID}) has exited unexpectedly"
+ echo "=== Server log ==="
+ cat /tmp/gh-aw/mcp-logs/qmd/server.log
+ exit 1
+ fi
+
+ # Poll the health endpoint
+ if curl -s -f --max-time 2 --connect-timeout 1 \
+ "http://localhost:${GH_AW_QMD_PORT}/health" > /dev/null 2>&1; then
+ ELAPSED_MS=$(( $(date +%s%3N) - HEALTH_START ))
+ echo "qmd MCP server is ready after ${ELAPSED_MS}ms (attempt ${ATTEMPT}/${MAX_ATTEMPTS})"
+ echo ""
+ echo "::group::qmd server startup log"
+ cat /tmp/gh-aw/mcp-logs/qmd/server.log
+ echo "::endgroup::"
+ break
+ fi
+
+ # Log progress every 30 seconds
+ if [ $(( ATTEMPT % 30 )) -eq 0 ]; then
+ ELAPSED_SEC=$(( ($(date +%s%3N) - HEALTH_START) / 1000 ))
+ echo "Still waiting... (attempt ${ATTEMPT}/${MAX_ATTEMPTS}, ${ELAPSED_SEC}s elapsed)"
+ # Show last few log lines for diagnostics
+ tail -5 /tmp/gh-aw/mcp-logs/qmd/server.log 2>/dev/null || true
+ fi
+
+ if [ "${ATTEMPT}" -eq "${MAX_ATTEMPTS}" ]; then
+ echo "ERROR: qmd MCP server failed to respond within ${TIMEOUT_SECONDS}s"
+ echo "Last HTTP check on http://localhost:${GH_AW_QMD_PORT}/health failed."
+ echo ""
+ echo "=== Server log (full) ==="
+ cat /tmp/gh-aw/mcp-logs/qmd/server.log
+ echo ""
+ echo "Checking port availability:"
+ ss -tuln 2>/dev/null | grep "${GH_AW_QMD_PORT}" || \
+ netstat -tuln 2>/dev/null | grep "${GH_AW_QMD_PORT}" || \
+ echo "Port ${GH_AW_QMD_PORT} not listed (ss/netstat not available)"
+ exit 1
+ fi
+
+ sleep "${RETRY_DELAY}"
+done
+
+# Write the port to GITHUB_OUTPUT so downstream steps can reference it
+{
+ echo "port=${GH_AW_QMD_PORT}"
+} >> "${GITHUB_OUTPUT}"
+
+echo "qmd MCP server started successfully on port ${GH_AW_QMD_PORT}"
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 9a7896362fe..8d9857b700b 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -236,6 +236,9 @@ const (
// DefaultMCPInspectorPort is the default port for the MCP inspector (safe-outputs server)
DefaultMCPInspectorPort = 3001
+ // DefaultQmdPort is the default HTTP port for the qmd MCP server
+ DefaultQmdPort = 3002
+
// MinNetworkPort is the minimum valid network port number
MinNetworkPort = 1
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index a0f73f82e3a..8223c175300 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -290,6 +290,8 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
yaml.WriteString(generateQmdIndexCacheRestoreStep(data.QmdConfig))
compilerYamlLog.Print("Adding qmd models cache restore step (read-only)")
yaml.WriteString(generateQmdModelsCacheRestoreStep())
+ compilerYamlLog.Print("Adding qmd MCP server start step")
+ yaml.WriteString(generateQmdStartServerStep(data.QmdConfig))
}
// GH_AW_SAFE_OUTPUTS is now set at job level, no setup step needed
diff --git a/pkg/workflow/mcp_environment.go b/pkg/workflow/mcp_environment.go
index 41190f37828..94bfa9af70a 100644
--- a/pkg/workflow/mcp_environment.go
+++ b/pkg/workflow/mcp_environment.go
@@ -124,6 +124,12 @@ func collectMCPEnvironmentVariables(tools map[string]any, mcpTools []string, wor
envVars["GH_AW_SAFE_OUTPUTS_API_KEY"] = "${{ steps.safe-outputs-start.outputs.api_key }}"
}
+ // Add qmd server port env var if qmd tool is configured.
+ // The port is emitted by the "Start qmd MCP HTTP server" step (id: qmd-server-start).
+ if workflowData != nil && workflowData.QmdConfig != nil {
+ envVars["GH_AW_QMD_PORT"] = "${{ steps.qmd-server-start.outputs.port }}"
+ }
+
// Check for agentic-workflows GITHUB_TOKEN
if hasAgenticWorkflows {
envVars["GITHUB_TOKEN"] = "${{ secrets.GITHUB_TOKEN }}"
diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go
index 0ac035580d9..5fe3739f9e5 100644
--- a/pkg/workflow/mcp_renderer_builtin.go
+++ b/pkg/workflow/mcp_renderer_builtin.go
@@ -88,69 +88,27 @@ func (r *MCPConfigRendererUnified) RenderQmdMCP(yaml *strings.Builder, qmdTool a
renderQmdMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs)
}
-// renderQmdTOML generates qmd MCP configuration in TOML format.
-// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized.
+// renderQmdTOML generates qmd MCP configuration in TOML format using HTTP transport.
+// The qmd MCP server is started separately in the agent job by start_qmd_server.sh and
+// listens on GH_AW_QMD_PORT. Using HTTP transport (instead of stdio+container) allows
+// the server to boot once and be probed for health before the gateway starts.
func (r *MCPConfigRendererUnified) renderQmdTOML(yaml *strings.Builder) {
- mcpRendererBuiltinLog.Print("Rendering qmd MCP in TOML format")
+ mcpRendererBuiltinLog.Print("Rendering qmd MCP in TOML format (HTTP transport)")
yaml.WriteString(" \n")
yaml.WriteString(" [mcp_servers.qmd]\n")
- yaml.WriteString(" container = \"" + string(constants.DefaultNodeAlpineLTSImage) + "\"\n")
-
- // Entrypoint for qmd MCP server
- yaml.WriteString(" entrypoint = \"npx\"\n")
- yaml.WriteString(" entrypointArgs = [\n")
- yaml.WriteString(" \"@tobilu/qmd@" + string(constants.DefaultQmdVersion) + "\",\n")
- yaml.WriteString(" \"serve-mcp\",\n")
- yaml.WriteString(" ]\n")
-
- // Mount the pre-built index (downloaded from activation artifact)
- yaml.WriteString(" mounts = [\"/tmp/gh-aw/qmd-index:/tmp/gh-aw/qmd-index:ro\"]\n")
- yaml.WriteString(" env_vars = [\"QMD_CACHE_DIR\"]\n")
+ yaml.WriteString(" type = \"http\"\n")
+ yaml.WriteString(" url = \"http://host.docker.internal:$GH_AW_QMD_PORT\"\n")
}
// renderQmdMCPConfigWithOptions generates the qmd MCP server configuration in JSON format.
-func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, inlineArgs bool) {
+// qmd is exposed via HTTP transport — the server was started (and health-probed) before
+// the gateway by the "Start qmd MCP HTTP server" step.
+// _includeCopilotFields and _inlineArgs are unused after the HTTP transport migration.
+func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, _includeCopilotFields bool, _inlineArgs bool) {
yaml.WriteString(" \"qmd\": {\n")
-
- if includeCopilotFields {
- yaml.WriteString(" \"type\": \"stdio\",\n")
- }
-
- yaml.WriteString(" \"container\": \"" + string(constants.DefaultNodeAlpineLTSImage) + "\",\n")
- yaml.WriteString(" \"entrypoint\": \"npx\",\n")
-
- entrypointArgs := []string{
- "@tobilu/qmd@" + string(constants.DefaultQmdVersion),
- "serve-mcp",
- }
-
- if inlineArgs {
- yaml.WriteString(" \"entrypointArgs\": [")
- for i, arg := range entrypointArgs {
- if i > 0 {
- yaml.WriteString(", ")
- }
- yaml.WriteString("\"" + arg + "\"")
- }
- yaml.WriteString("],\n")
- } else {
- yaml.WriteString(" \"entrypointArgs\": [\n")
- for i, arg := range entrypointArgs {
- yaml.WriteString(" \"" + arg + "\"")
- if i < len(entrypointArgs)-1 {
- yaml.WriteString(",")
- }
- yaml.WriteString("\n")
- }
- yaml.WriteString(" ],\n")
- }
-
- // Mount the pre-built index read-only; pass QMD_CACHE_DIR so the server finds it
- yaml.WriteString(" \"mounts\": [\"/tmp/gh-aw/qmd-index:/tmp/gh-aw/qmd-index:ro\"],\n")
- yaml.WriteString(" \"env\": {\n")
- yaml.WriteString(" \"QMD_CACHE_DIR\": \"/tmp/gh-aw/qmd-index\"\n")
- yaml.WriteString(" }\n")
+ yaml.WriteString(" \"type\": \"http\",\n")
+ yaml.WriteString(" \"url\": \"http://host.docker.internal:$GH_AW_QMD_PORT\"\n")
if isLast {
yaml.WriteString(" }\n")
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 7621db795ba..412bdd6402a 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -483,6 +483,34 @@ func generateQmdDownloadStep(data *WorkflowData) string {
return sb.String()
}
+// generateQmdStartServerStep generates the agent job step that starts the qmd MCP server
+// with HTTP transport. The server is started before the MCP gateway so that node-llama-cpp
+// has time to download llama.cpp binaries and embedding model weights if needed — this can
+// take several minutes on the first run. A health probe loop in start_qmd_server.sh waits
+// up to 10 minutes for the server to become ready before the agent starts.
+//
+// The step id is "qmd-server-start" and it emits a "port" output used by downstream steps.
+func generateQmdStartServerStep(qmdConfig *QmdToolConfig) string {
+ var sb strings.Builder
+ sb.WriteString(" - name: Start qmd MCP HTTP server\n")
+ sb.WriteString(" id: qmd-server-start\n")
+ sb.WriteString(" env:\n")
+ fmt.Fprintf(&sb, " GH_AW_QMD_PORT: \"%d\"\n", constants.DefaultQmdPort)
+ sb.WriteString(" QMD_CACHE_DIR: /tmp/gh-aw/qmd-index\n")
+ // Disable GPU by default; only enable when the user explicitly opts in.
+ if !qmdConfig.GPU {
+ sb.WriteString(" NODE_LLAMA_CPP_GPU: \"false\"\n")
+ }
+ sb.WriteString(" run: |\n")
+ sb.WriteString(" export GH_AW_QMD_PORT\n")
+ sb.WriteString(" export QMD_CACHE_DIR\n")
+ if !qmdConfig.GPU {
+ sb.WriteString(" export NODE_LLAMA_CPP_GPU\n")
+ }
+ sb.WriteString(" bash ${RUNNER_TEMP}/gh-aw/actions/start_qmd_server.sh\n")
+ return sb.String()
+}
+
// buildQmdIndexingJob builds a standalone "indexing" job that depends on the activation job
// and builds the qmd documentation search index.
//
From d8aba880c8254014ca8df79e154880a1c7522e4a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 09:22:58 +0000
Subject: [PATCH 23/49] Remove qmd index cache restore from agent job; default
indexing to ubuntu-latest
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ece7b2a4-efef-4275-baf2-ccbee0e146ea
---
.github/workflows/smoke-codex.lock.yml | 9 +--------
pkg/workflow/compiler_yaml_main_job.go | 7 ++-----
pkg/workflow/qmd.go | 4 ++--
3 files changed, 5 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index b57d4063027..1cac11579a8 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -428,13 +428,6 @@ jobs:
with:
name: qmd-index
path: /tmp/gh-aw/qmd-index/
- - name: Restore qmd index from cache
- uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
- with:
- key: gh-aw-qmd-${{ github.run_id }}
- path: /tmp/gh-aw/qmd-index/
- restore-keys: |
- gh-aw-qmd-
- name: Restore qmd models cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
@@ -1496,7 +1489,7 @@ jobs:
indexing:
needs: activation
- runs-on: ubuntu-slim
+ runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 60
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index 8223c175300..d911d76248a 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -280,14 +280,11 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
}
// Download qmd index artifact if qmd tool is configured.
- // The index was built in the indexing job (which has contents:read).
- // In addition to the artifact, the agent also restores from the Actions cache so that
- // the index is available even if the artifact has expired.
+ // The index was built in the indexing job (which has contents:read) and is
+ // available via the downloaded artifact; no additional cache restore is needed.
if data.QmdConfig != nil {
compilerYamlLog.Print("Adding qmd index download step")
yaml.WriteString(generateQmdDownloadStep(data))
- compilerYamlLog.Print("Adding qmd index cache restore step (read-only)")
- yaml.WriteString(generateQmdIndexCacheRestoreStep(data.QmdConfig))
compilerYamlLog.Print("Adding qmd models cache restore step (read-only)")
yaml.WriteString(generateQmdModelsCacheRestoreStep())
compilerYamlLog.Print("Adding qmd MCP server start step")
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 412bdd6402a..9da1491ca55 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -566,8 +566,8 @@ func (c *Compiler) buildQmdIndexingJob(data *WorkflowData) (*Job, error) {
})
// Determine the runner for the indexing job.
- // The user can override via qmd.runs-on; otherwise fall back to the safe-outputs runner.
- indexingRunsOn := c.formatSafeOutputsRunsOn(data.SafeOutputs)
+ // Default to ubuntu-latest; user can override via qmd.runs-on.
+ indexingRunsOn := "runs-on: ubuntu-latest"
if data.QmdConfig.RunsOn != "" {
indexingRunsOn = "runs-on: " + data.QmdConfig.RunsOn
}
From 38a173d8225951ebc5b35e07db3e8bb1d3cdb1a3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 14:55:33 +0000
Subject: [PATCH 24/49] feat(qmd): also cache node-llama-cpp binaries in model
cache steps
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/232e0cdf-c0e7-4b91-bbc1-f92b9e24fd77
---
.github/workflows/smoke-codex.lock.yml | 8 ++++++--
pkg/workflow/qmd.go | 16 +++++++++++-----
2 files changed, 17 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 1cac11579a8..597313ee6f8 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -431,7 +431,9 @@ jobs:
- name: Restore qmd models cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
- path: ~/.cache/qmd/models/
+ path: |
+ ~/.cache/qmd/models/
+ ~/.cache/node-llama-cpp/
key: qmd-models-${{ runner.os }}
- name: Start qmd MCP HTTP server
id: qmd-server-start
@@ -1520,7 +1522,9 @@ jobs:
- name: Cache qmd models
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
- path: ~/.cache/qmd/models/
+ path: |
+ ~/.cache/qmd/models/
+ ~/.cache/node-llama-cpp/
key: qmd-models-${{ runner.os }}
- name: Setup Node.js for qmd
if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 9da1491ca55..c03e699ce91 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -90,8 +90,9 @@ func qmdHasSources(qmdConfig *QmdToolConfig) bool {
}
// generateQmdModelsCacheStep generates a step that caches the qmd embedding models directory
-// (~/.cache/qmd/models/). It uses the combined actions/cache action (restore + post-save),
-// keyed by OS so that the cached models are compatible with the runner architecture.
+// (~/.cache/qmd/models/) and the node-llama-cpp downloaded binaries (~/.cache/node-llama-cpp/).
+// It uses the combined actions/cache action (restore + post-save), keyed by OS so that the
+// cached models and binaries are compatible with the runner architecture.
// This step should be emitted in the activation job (before index building) to populate
// the cache. For the agent job, use generateQmdModelsCacheRestoreStep instead.
func generateQmdModelsCacheStep() string {
@@ -99,13 +100,16 @@ func generateQmdModelsCacheStep() string {
sb.WriteString(" - name: Cache qmd models\n")
fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache"))
sb.WriteString(" with:\n")
- sb.WriteString(" path: ~/.cache/qmd/models/\n")
+ sb.WriteString(" path: |\n")
+ sb.WriteString(" ~/.cache/qmd/models/\n")
+ sb.WriteString(" ~/.cache/node-llama-cpp/\n")
sb.WriteString(" key: qmd-models-${{ runner.os }}\n")
return sb.String()
}
// generateQmdModelsCacheRestoreStep generates a read-only step that restores the qmd embedding
-// models directory (~/.cache/qmd/models/) from GitHub Actions cache. It uses
+// models directory (~/.cache/qmd/models/) and node-llama-cpp downloaded binaries
+// (~/.cache/node-llama-cpp/) from GitHub Actions cache. It uses
// actions/cache/restore (restore-only, no post-save) so the agent job never writes to the
// shared cache — that is the indexing job's responsibility.
func generateQmdModelsCacheRestoreStep() string {
@@ -113,7 +117,9 @@ func generateQmdModelsCacheRestoreStep() string {
sb.WriteString(" - name: Restore qmd models cache\n")
fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/restore"))
sb.WriteString(" with:\n")
- sb.WriteString(" path: ~/.cache/qmd/models/\n")
+ sb.WriteString(" path: |\n")
+ sb.WriteString(" ~/.cache/qmd/models/\n")
+ sb.WriteString(" ~/.cache/node-llama-cpp/\n")
sb.WriteString(" key: qmd-models-${{ runner.os }}\n")
return sb.String()
}
From 4dedc393fa5398c3f723454643a4b1fcfd8f63ae Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 15:18:09 +0000
Subject: [PATCH 25/49] fix: remove qmd artifact, use exact cache key in agent
job, fix npx syntax
- Remove artifact upload from indexing job and artifact download from agent job
since the qmd index is already saved to GitHub Actions cache
- Agent job now restores using the precise cache key (no restore-keys fallback)
so it always gets the exact index built by the current run's indexing job
- Fix npx syntax: use --package=@tobilu/qmd serve-mcp instead of
"@tobilu/qmd" serve-mcp (the serve-mcp binary needs to be invoked
via --package flag, not as a subcommand of the qmd binary)
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e6e6c751-a2eb-4af4-8b24-3fa19ec1a53b
---
.github/workflows/smoke-codex.lock.yml | 13 ++---
actions/setup/sh/start_qmd_server.sh | 2 +-
pkg/workflow/compiler_yaml_main_job.go | 10 ++--
pkg/workflow/qmd.go | 72 +++++++-------------------
4 files changed, 29 insertions(+), 68 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 597313ee6f8..32ddb87a0d6 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -423,10 +423,10 @@ jobs:
run: npm install -g @openai/codex@latest
- name: Install AWF binary
run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5
- - name: Download qmd index artifact
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - name: Restore qmd index from cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
- name: qmd-index
+ key: gh-aw-qmd-${{ github.run_id }}
path: /tmp/gh-aw/qmd-index/
- name: Restore qmd models cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
@@ -1556,13 +1556,6 @@ jobs:
with:
key: gh-aw-qmd-${{ github.run_id }}
path: /tmp/gh-aw/qmd-index/
- - name: Upload qmd index artifact
- if: success()
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- with:
- name: qmd-index
- path: /tmp/gh-aw/qmd-index/
- retention-days: 1
pre_activation:
if: >
diff --git a/actions/setup/sh/start_qmd_server.sh b/actions/setup/sh/start_qmd_server.sh
index b2d35b6f4a0..93df15bbc24 100644
--- a/actions/setup/sh/start_qmd_server.sh
+++ b/actions/setup/sh/start_qmd_server.sh
@@ -36,7 +36,7 @@ mkdir -p /tmp/gh-aw/mcp-logs/qmd
# NODE_LLAMA_CPP_GPU controls GPU probing; "false" disables it on CPU runners.
QMD_CACHE_DIR="${QMD_CACHE_DIR}" \
NODE_LLAMA_CPP_GPU="${NODE_LLAMA_CPP_GPU:-false}" \
- npx "@tobilu/qmd" serve-mcp --http --port "${GH_AW_QMD_PORT}" \
+ npx --package=@tobilu/qmd serve-mcp --http --port "${GH_AW_QMD_PORT}" \
>> /tmp/gh-aw/mcp-logs/qmd/server.log 2>&1 &
SERVER_PID=$!
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index d911d76248a..e688b13a504 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -279,12 +279,12 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
}
}
- // Download qmd index artifact if qmd tool is configured.
- // The index was built in the indexing job (which has contents:read) and is
- // available via the downloaded artifact; no additional cache restore is needed.
+ // Restore qmd index from cache if qmd tool is configured.
+ // The index was built and cached in the indexing job; we restore it using the precise
+ // cache key so we always get the index from the current workflow run.
if data.QmdConfig != nil {
- compilerYamlLog.Print("Adding qmd index download step")
- yaml.WriteString(generateQmdDownloadStep(data))
+ compilerYamlLog.Print("Adding qmd index exact-key cache restore step")
+ yaml.WriteString(generateQmdIndexCacheRestoreExactStep(data.QmdConfig))
compilerYamlLog.Print("Adding qmd models cache restore step (read-only)")
yaml.WriteString(generateQmdModelsCacheRestoreStep())
compilerYamlLog.Print("Adding qmd MCP server start step")
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index c03e699ce91..8fb62f5300d 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -11,14 +11,14 @@
// Does NOT build the qmd index.
//
// 2. Indexing job (new): runs after activation, builds the search index from configured
-// checkouts and/or GitHub searches, and uploads it as the "qmd-index" artifact.
+// checkouts and/or GitHub searches, and saves it to GitHub Actions cache.
// This job has contents:read permission so the agent job does NOT need it.
// The index is built by a single actions/github-script step that runs qmd_index.cjs,
// which uses the @tobilu/qmd JavaScript SDK to build the collections.
//
// 3. Agent job: depends on BOTH the activation job (for its outputs) and the indexing job
-// (for the qmd-index artifact). Downloads the pre-built index and mounts the qmd MCP
-// server pointing at it.
+// (for the qmd index cache). Restores the pre-built index from cache using the precise
+// cache key and mounts the qmd MCP server pointing at it.
//
// # Configuration
//
@@ -48,17 +48,18 @@
// github-token: ${{ secrets.GITHUB_TOKEN }}
// cache-key: "qmd-index-${{ hashFiles('docs/**') }}"
//
-// # Artifact lifecycle
+// # Cache lifecycle
//
-// The index is built once per indexing job run and shared with the agent job
-// via the "qmd-index" artifact. Retention is 1 day (same as the activation artifact).
+// The index is always stored in GitHub Actions cache. The default cache key is
+// gh-aw-qmd-${{ github.run_id }} (ephemeral per run). The agent job restores from
+// the exact same key that the indexing job saved, so no artifact upload/download is needed.
//
// Related files:
// - tools_types.go: QmdToolConfig, QmdDocCollection, QmdSearchEntry types
// - tools_parser.go: parseQmdTool / parseQmdDocCollection / parseQmdSearchEntry
// - mcp_renderer_builtin.go: RenderQmdMCP method
// - compiler_jobs.go: buildQmdIndexingJobWrapper
-// - compiler_yaml_main_job.go: agent job qmd artifact download
+// - compiler_yaml_main_job.go: agent job qmd cache restore
// - actions/setup/js/qmd_index.cjs: JavaScript SDK implementation
package workflow
@@ -124,25 +125,18 @@ func generateQmdModelsCacheRestoreStep() string {
return sb.String()
}
-// generateQmdIndexCacheRestoreStep generates a read-only restore step for the agent job that
-// restores the qmd search index from the Actions cache. It uses the same resolved cache key
-// as the indexing job so that the index is available even if the artifact has already expired.
-// restore-keys are included so the agent can fall back to a cached index from a previous run.
-func generateQmdIndexCacheRestoreStep(qmdConfig *QmdToolConfig) string {
+// generateQmdIndexCacheRestoreExactStep generates a read-only restore step for the agent job
+// that restores the qmd search index from Actions cache using the PRECISE cache key.
+// No restore-keys fallback is used — the agent job must get the exact index that the
+// indexing job saved in the current workflow run.
+func generateQmdIndexCacheRestoreExactStep(qmdConfig *QmdToolConfig) string {
cacheKey := resolveQmdCacheKey(qmdConfig)
- restoreKeys := resolveQmdRestoreKeys(qmdConfig)
var sb strings.Builder
sb.WriteString(" - name: Restore qmd index from cache\n")
fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/restore"))
sb.WriteString(" with:\n")
fmt.Fprintf(&sb, " key: %s\n", cacheKey)
sb.WriteString(" path: /tmp/gh-aw/qmd-index/\n")
- if len(restoreKeys) > 0 {
- sb.WriteString(" restore-keys: |\n")
- for _, rk := range restoreKeys {
- fmt.Fprintf(&sb, " %s\n", rk)
- }
- }
return sb.String()
}
@@ -354,8 +348,8 @@ func generateQmdCollectionCheckoutStep(col *QmdDocCollection) string {
}
// generateQmdIndexSteps generates the indexing job steps that install the @tobilu/qmd SDK,
-// run the qmd_index.cjs JavaScript script to build the vector search index, and upload it
-// as the qmd-index artifact.
+// run the qmd_index.cjs JavaScript script to build the vector search index, and save it
+// to GitHub Actions cache.
//
// The configuration is serialised to JSON and passed via the QMD_CONFIG_JSON environment
// variable to the github-script step. qmd_index.cjs uses the @tobilu/qmd SDK to:
@@ -369,11 +363,11 @@ func generateQmdCollectionCheckoutStep(col *QmdDocCollection) string {
// single workflow run). When qmdConfig.CacheKey IS set, the cache is durable across runs.
//
// Modes:
-// - Read-only mode (cache-key set, no sources): only cache restore + artifact upload.
+// - Read-only mode (cache-key set, no sources): only cache restore + cache save (skipped on hit).
// - Build mode (sources present): indexing steps are guarded by
// `if: steps.qmd-cache-restore.outputs.cache-hit != 'true'`, so they are skipped on a
// cache hit. A cache save step always follows.
-func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []string {
+func generateQmdIndexSteps(qmdConfig *QmdToolConfig) []string {
hasSources := qmdHasSources(qmdConfig)
isCacheOnlyMode := qmdConfig.CacheKey != "" && !hasSources
cacheKey := resolveQmdCacheKey(qmdConfig)
@@ -460,35 +454,9 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig, data *WorkflowData) []strin
steps = append(steps, generateQmdCacheSaveStep(cacheKey))
}
- // Upload qmd index as a separate artifact for the agent job
- qmdLog.Print("Adding qmd index artifact upload step")
- // The upload runs in the indexing job (a downstream job from activation), so use the
- // downstream prefix expression which references needs.activation.outputs.artifact_prefix.
- qmdArtifactName := artifactPrefixExprForDownstreamJob(data) + constants.QmdArtifactName
- steps = append(steps, " - name: Upload qmd index artifact\n")
- steps = append(steps, " if: success()\n")
- steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/upload-artifact")))
- steps = append(steps, " with:\n")
- steps = append(steps, fmt.Sprintf(" name: %s\n", qmdArtifactName))
- steps = append(steps, " path: /tmp/gh-aw/qmd-index/\n")
- steps = append(steps, " retention-days: 1\n")
-
return steps
}
-// generateQmdDownloadStep generates the agent job step that downloads the qmd-index artifact.
-// Returns the steps as a YAML string slice ready to be appended to the agent job steps.
-func generateQmdDownloadStep(data *WorkflowData) string {
- qmdArtifactName := artifactPrefixExprForDownstreamJob(data) + constants.QmdArtifactName
- var sb strings.Builder
- sb.WriteString(" - name: Download qmd index artifact\n")
- fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/download-artifact"))
- sb.WriteString(" with:\n")
- fmt.Fprintf(&sb, " name: %s\n", qmdArtifactName)
- sb.WriteString(" path: /tmp/gh-aw/qmd-index/\n")
- return sb.String()
-}
-
// generateQmdStartServerStep generates the agent job step that starts the qmd MCP server
// with HTTP transport. The server is started before the MCP gateway so that node-llama-cpp
// has time to download llama.cpp binaries and embedding model weights if needed — this can
@@ -525,7 +493,7 @@ func generateQmdStartServerStep(qmdConfig *QmdToolConfig) string {
// 2. Runs the setup action to copy qmd_index.cjs and setup_globals.cjs to the runner
// 3. Optionally checks out the workspace for checkout-based collections
// 4. Installs @tobilu/qmd and @actions/github and runs qmd_index.cjs via actions/github-script
-// 5. Uploads the resulting index as the qmd-index artifact
+// 5. Saves the resulting index to GitHub Actions cache
//
// The agent job declares a needs dependency on this "indexing" job and downloads the artifact.
func (c *Compiler) buildQmdIndexingJob(data *WorkflowData) (*Job, error) {
@@ -559,8 +527,8 @@ func (c *Compiler) buildQmdIndexingJob(data *WorkflowData) (*Job, error) {
steps = append(steps, sb.String())
}
- // Generate all qmd index-building steps (cache restore/save, Node.js, SDK install, github-script, artifact upload).
- qmdSteps := generateQmdIndexSteps(data.QmdConfig, data)
+ // Generate all qmd index-building steps (cache restore/save, Node.js, SDK install, github-script).
+ qmdSteps := generateQmdIndexSteps(data.QmdConfig)
steps = append(steps, qmdSteps...)
// The indexing job runs after the activation job to inherit the artifact prefix output.
From cda60c778c0b41961c72b8a91074ba42aed1c468 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 15:48:36 +0000
Subject: [PATCH 26/49] Split qmd models and node-llama-cpp into separate
caches with arch-specific key
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4525e593-734d-4ce7-9e84-093befd24192
---
.github/workflows/smoke-codex.lock.yml | 18 ++++++---
pkg/workflow/compiler_yaml_main_job.go | 2 +
pkg/workflow/qmd.go | 52 ++++++++++++++++++++------
3 files changed, 54 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 32ddb87a0d6..3573f34cbbe 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -431,10 +431,13 @@ jobs:
- name: Restore qmd models cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
- path: |
- ~/.cache/qmd/models/
- ~/.cache/node-llama-cpp/
+ path: ~/.cache/qmd/models/
key: qmd-models-${{ runner.os }}
+ - name: Restore node-llama-cpp cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/node-llama-cpp/
+ key: node-llama-cpp-${{ runner.os }}-${{ runner.arch }}
- name: Start qmd MCP HTTP server
id: qmd-server-start
env:
@@ -1522,10 +1525,13 @@ jobs:
- name: Cache qmd models
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
- path: |
- ~/.cache/qmd/models/
- ~/.cache/node-llama-cpp/
+ path: ~/.cache/qmd/models/
key: qmd-models-${{ runner.os }}
+ - name: Cache node-llama-cpp binaries
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/node-llama-cpp/
+ key: node-llama-cpp-${{ runner.os }}-${{ runner.arch }}
- name: Setup Node.js for qmd
if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index e688b13a504..b700d9bd514 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -287,6 +287,8 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
yaml.WriteString(generateQmdIndexCacheRestoreExactStep(data.QmdConfig))
compilerYamlLog.Print("Adding qmd models cache restore step (read-only)")
yaml.WriteString(generateQmdModelsCacheRestoreStep())
+ compilerYamlLog.Print("Adding node-llama-cpp cache restore step (read-only)")
+ yaml.WriteString(generateQmdNodeLlamaCppCacheRestoreStep())
compilerYamlLog.Print("Adding qmd MCP server start step")
yaml.WriteString(generateQmdStartServerStep(data.QmdConfig))
}
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 8fb62f5300d..6574b8deb90 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -91,26 +91,37 @@ func qmdHasSources(qmdConfig *QmdToolConfig) bool {
}
// generateQmdModelsCacheStep generates a step that caches the qmd embedding models directory
-// (~/.cache/qmd/models/) and the node-llama-cpp downloaded binaries (~/.cache/node-llama-cpp/).
-// It uses the combined actions/cache action (restore + post-save), keyed by OS so that the
-// cached models and binaries are compatible with the runner architecture.
-// This step should be emitted in the activation job (before index building) to populate
+// (~/.cache/qmd/models/) using the actions/cache action (restore + post-save), keyed by OS.
+// This step should be emitted in the indexing job (before index building) to populate
// the cache. For the agent job, use generateQmdModelsCacheRestoreStep instead.
func generateQmdModelsCacheStep() string {
var sb strings.Builder
sb.WriteString(" - name: Cache qmd models\n")
fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache"))
sb.WriteString(" with:\n")
- sb.WriteString(" path: |\n")
- sb.WriteString(" ~/.cache/qmd/models/\n")
- sb.WriteString(" ~/.cache/node-llama-cpp/\n")
+ sb.WriteString(" path: ~/.cache/qmd/models/\n")
sb.WriteString(" key: qmd-models-${{ runner.os }}\n")
return sb.String()
}
+// generateQmdNodeLlamaCppCacheStep generates a step that caches the node-llama-cpp downloaded
+// binaries (~/.cache/node-llama-cpp/) using the actions/cache action (restore + post-save).
+// The cache key includes both OS and CPU architecture because the node-llama-cpp binaries are
+// compiled native code that must match the exact runner image platform.
+// This step should be emitted in the indexing job. For the agent job, use
+// generateQmdNodeLlamaCppCacheRestoreStep instead.
+func generateQmdNodeLlamaCppCacheStep() string {
+ var sb strings.Builder
+ sb.WriteString(" - name: Cache node-llama-cpp binaries\n")
+ fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache"))
+ sb.WriteString(" with:\n")
+ sb.WriteString(" path: ~/.cache/node-llama-cpp/\n")
+ sb.WriteString(" key: node-llama-cpp-${{ runner.os }}-${{ runner.arch }}\n")
+ return sb.String()
+}
+
// generateQmdModelsCacheRestoreStep generates a read-only step that restores the qmd embedding
-// models directory (~/.cache/qmd/models/) and node-llama-cpp downloaded binaries
-// (~/.cache/node-llama-cpp/) from GitHub Actions cache. It uses
+// models directory (~/.cache/qmd/models/) from GitHub Actions cache. It uses
// actions/cache/restore (restore-only, no post-save) so the agent job never writes to the
// shared cache — that is the indexing job's responsibility.
func generateQmdModelsCacheRestoreStep() string {
@@ -118,13 +129,26 @@ func generateQmdModelsCacheRestoreStep() string {
sb.WriteString(" - name: Restore qmd models cache\n")
fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/restore"))
sb.WriteString(" with:\n")
- sb.WriteString(" path: |\n")
- sb.WriteString(" ~/.cache/qmd/models/\n")
- sb.WriteString(" ~/.cache/node-llama-cpp/\n")
+ sb.WriteString(" path: ~/.cache/qmd/models/\n")
sb.WriteString(" key: qmd-models-${{ runner.os }}\n")
return sb.String()
}
+// generateQmdNodeLlamaCppCacheRestoreStep generates a read-only step that restores the
+// node-llama-cpp downloaded binaries (~/.cache/node-llama-cpp/) from GitHub Actions cache.
+// It uses actions/cache/restore (restore-only, no post-save) so the agent job never writes
+// to the shared cache — that is the indexing job's responsibility.
+// The cache key includes both OS and CPU architecture to ensure binary compatibility.
+func generateQmdNodeLlamaCppCacheRestoreStep() string {
+ var sb strings.Builder
+ sb.WriteString(" - name: Restore node-llama-cpp cache\n")
+ fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/restore"))
+ sb.WriteString(" with:\n")
+ sb.WriteString(" path: ~/.cache/node-llama-cpp/\n")
+ sb.WriteString(" key: node-llama-cpp-${{ runner.os }}-${{ runner.arch }}\n")
+ return sb.String()
+}
+
// generateQmdIndexCacheRestoreExactStep generates a read-only restore step for the agent job
// that restores the qmd search index from Actions cache using the PRECISE cache key.
// No restore-keys fallback is used — the agent job must get the exact index that the
@@ -381,7 +405,11 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig) []string {
steps = append(steps, generateQmdCacheRestoreStep(qmdConfig))
// Always cache qmd embedding models to avoid re-downloading on each run
+ // Cache qmd models and node-llama-cpp binaries in separate caches so they can be
+ // invalidated independently. The node-llama-cpp key also includes the CPU architecture
+ // because those binaries are compiled native code that must match the runner platform.
steps = append(steps, generateQmdModelsCacheStep())
+ steps = append(steps, generateQmdNodeLlamaCppCacheStep())
// Cache-only mode: no indexing at all — just use the restored cache
if isCacheOnlyMode {
From e960630a437c913d20ab930e2db32c6e8d08d163 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 16:33:11 +0000
Subject: [PATCH 27/49] Fix qmd MCP server startup and add version to all cache
keys
- Fix start_qmd_server.sh: use `qmd mcp --http` instead of `serve-mcp --http`
(serve-mcp is not a valid binary; the correct CLI subcommand is `mcp --http`)
- Use INDEX_PATH env var so qmd CLI finds the restored index at
/tmp/gh-aw/qmd-index/index.sqlite (overrides getDefaultDbPath())
- Pass GH_AW_QMD_VERSION to npx for version-pinned package install
- Remove QMD_CACHE_DIR (not a real qmd env var, has no effect)
- Include DefaultQmdVersion in all actions/cache keys:
- qmd index: gh-aw-qmd-2.0.1-${{ github.run_id }}
- qmd models: qmd-models-2.0.1-${{ runner.os }}
- node-llama-cpp: node-llama-cpp-2.0.1-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}
- Remove misleading QMD_CACHE_DIR assignment in qmd_index.cjs
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/3e2dfdb1-7152-4977-8ee3-b10482939bae
---
.github/workflows/smoke-codex.lock.yml | 22 +++++++-------
actions/setup/js/qmd_index.cjs | 3 --
actions/setup/sh/start_qmd_server.sh | 12 ++++----
pkg/workflow/mcp_renderer_builtin.go | 4 +--
pkg/workflow/qmd.go | 41 ++++++++++++++++----------
5 files changed, 47 insertions(+), 35 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 3573f34cbbe..2f2651bc6e6 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -426,27 +426,29 @@ jobs:
- name: Restore qmd index from cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
- key: gh-aw-qmd-${{ github.run_id }}
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
path: /tmp/gh-aw/qmd-index/
- name: Restore qmd models cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.cache/qmd/models/
- key: qmd-models-${{ runner.os }}
+ key: qmd-models-2.0.1-${{ runner.os }}
- name: Restore node-llama-cpp cache
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.cache/node-llama-cpp/
- key: node-llama-cpp-${{ runner.os }}-${{ runner.arch }}
+ key: node-llama-cpp-2.0.1-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}
- name: Start qmd MCP HTTP server
id: qmd-server-start
env:
GH_AW_QMD_PORT: "3002"
- QMD_CACHE_DIR: /tmp/gh-aw/qmd-index
+ INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
+ GH_AW_QMD_VERSION: "2.0.1"
NODE_LLAMA_CPP_GPU: "false"
run: |
export GH_AW_QMD_PORT
- export QMD_CACHE_DIR
+ export INDEX_PATH
+ export GH_AW_QMD_VERSION
export NODE_LLAMA_CPP_GPU
bash ${RUNNER_TEMP}/gh-aw/actions/start_qmd_server.sh
- name: Determine automatic lockdown mode for GitHub MCP Server
@@ -1518,20 +1520,20 @@ jobs:
id: qmd-cache-restore
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
- key: gh-aw-qmd-${{ github.run_id }}
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
path: /tmp/gh-aw/qmd-index/
restore-keys: |
- gh-aw-qmd-
+ gh-aw-qmd-2.0.1-
- name: Cache qmd models
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.cache/qmd/models/
- key: qmd-models-${{ runner.os }}
+ key: qmd-models-2.0.1-${{ runner.os }}
- name: Cache node-llama-cpp binaries
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.cache/node-llama-cpp/
- key: node-llama-cpp-${{ runner.os }}-${{ runner.arch }}
+ key: node-llama-cpp-2.0.1-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}
- name: Setup Node.js for qmd
if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
@@ -1560,7 +1562,7 @@ jobs:
if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
- key: gh-aw-qmd-${{ github.run_id }}
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
path: /tmp/gh-aw/qmd-index/
pre_activation:
diff --git a/actions/setup/js/qmd_index.cjs b/actions/setup/js/qmd_index.cjs
index 61b95a5d15f..02453c82097 100644
--- a/actions/setup/js/qmd_index.cjs
+++ b/actions/setup/js/qmd_index.cjs
@@ -228,9 +228,6 @@ async function main() {
// ── Create store and build index ─────────────────────────────────────────
core.info(`Creating qmd store at ${dbPath}…`);
- // Set QMD_CACHE_DIR so the SDK stores model weights in the index directory.
- process.env.QMD_CACHE_DIR = config.dbPath;
-
const store = await createStore({ dbPath, config: { collections } });
let updateResult = null;
diff --git a/actions/setup/sh/start_qmd_server.sh b/actions/setup/sh/start_qmd_server.sh
index 93df15bbc24..0b6ca192434 100644
--- a/actions/setup/sh/start_qmd_server.sh
+++ b/actions/setup/sh/start_qmd_server.sh
@@ -8,7 +8,9 @@
#
# Required environment variables:
# GH_AW_QMD_PORT - Port to listen on (e.g. 3002)
-# QMD_CACHE_DIR - Path to the pre-built qmd index (e.g. /tmp/gh-aw/qmd-index)
+# GH_AW_QMD_VERSION - @tobilu/qmd package version to use (e.g. 2.0.1)
+# INDEX_PATH - Path to the pre-built qmd index SQLite file
+# (e.g. /tmp/gh-aw/qmd-index/index.sqlite)
#
# Optional environment variables:
# NODE_LLAMA_CPP_GPU - Set to "false" to disable GPU probing (default: "false")
@@ -17,7 +19,7 @@ set -e
echo "Starting qmd MCP HTTP server..."
echo " Port: ${GH_AW_QMD_PORT}"
-echo " Cache dir: ${QMD_CACHE_DIR}"
+echo " Index: ${INDEX_PATH}"
echo " GPU: ${NODE_LLAMA_CPP_GPU:-auto}"
# Ensure logs directory exists
@@ -32,11 +34,11 @@ mkdir -p /tmp/gh-aw/mcp-logs/qmd
} > /tmp/gh-aw/mcp-logs/qmd/server.log
# Start the qmd MCP server with HTTP transport in the background.
-# QMD_CACHE_DIR tells qmd where the pre-built vector index lives.
+# INDEX_PATH tells qmd where the pre-built vector index SQLite file lives.
# NODE_LLAMA_CPP_GPU controls GPU probing; "false" disables it on CPU runners.
-QMD_CACHE_DIR="${QMD_CACHE_DIR}" \
+INDEX_PATH="${INDEX_PATH}" \
NODE_LLAMA_CPP_GPU="${NODE_LLAMA_CPP_GPU:-false}" \
- npx --package=@tobilu/qmd serve-mcp --http --port "${GH_AW_QMD_PORT}" \
+ npx --package="@tobilu/qmd@${GH_AW_QMD_VERSION}" qmd mcp --http --port "${GH_AW_QMD_PORT}" \
>> /tmp/gh-aw/mcp-logs/qmd/server.log 2>&1 &
SERVER_PID=$!
diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go
index 5fe3739f9e5..2f1cff60979 100644
--- a/pkg/workflow/mcp_renderer_builtin.go
+++ b/pkg/workflow/mcp_renderer_builtin.go
@@ -74,8 +74,8 @@ func (r *MCPConfigRendererUnified) renderPlaywrightTOML(yaml *strings.Builder, p
}
// RenderQmdMCP generates the qmd documentation search MCP server configuration.
-// qmd uses a Node.js container running @tobilu/qmd serve-mcp and mounts the pre-built index
-// that was downloaded from the activation job artifact.
+// qmd uses HTTP transport (qmd mcp --http) to serve the pre-built index over a local port.
+// The qmd server is started before the MCP gateway and the agent connects via HTTP.
func (r *MCPConfigRendererUnified) RenderQmdMCP(yaml *strings.Builder, qmdTool any) {
mcpRendererLog.Printf("Rendering qmd MCP: format=%s, inline_args=%t", r.options.Format, r.options.InlineArgs)
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 6574b8deb90..433c3239c3a 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -91,32 +91,34 @@ func qmdHasSources(qmdConfig *QmdToolConfig) bool {
}
// generateQmdModelsCacheStep generates a step that caches the qmd embedding models directory
-// (~/.cache/qmd/models/) using the actions/cache action (restore + post-save), keyed by OS.
-// This step should be emitted in the indexing job (before index building) to populate
-// the cache. For the agent job, use generateQmdModelsCacheRestoreStep instead.
+// (~/.cache/qmd/models/) using the actions/cache action (restore + post-save), keyed by OS
+// and qmd version. This step should be emitted in the indexing job (before index building) to
+// populate the cache. For the agent job, use generateQmdModelsCacheRestoreStep instead.
func generateQmdModelsCacheStep() string {
+ version := string(constants.DefaultQmdVersion)
var sb strings.Builder
sb.WriteString(" - name: Cache qmd models\n")
fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache"))
sb.WriteString(" with:\n")
sb.WriteString(" path: ~/.cache/qmd/models/\n")
- sb.WriteString(" key: qmd-models-${{ runner.os }}\n")
+ fmt.Fprintf(&sb, " key: qmd-models-%s-${{ runner.os }}\n", version)
return sb.String()
}
// generateQmdNodeLlamaCppCacheStep generates a step that caches the node-llama-cpp downloaded
// binaries (~/.cache/node-llama-cpp/) using the actions/cache action (restore + post-save).
-// The cache key includes both OS and CPU architecture because the node-llama-cpp binaries are
-// compiled native code that must match the exact runner image platform.
+// The cache key includes the qmd version, OS, CPU architecture, and runner image ID because
+// node-llama-cpp binaries are compiled native code that must match the exact runner image platform.
// This step should be emitted in the indexing job. For the agent job, use
// generateQmdNodeLlamaCppCacheRestoreStep instead.
func generateQmdNodeLlamaCppCacheStep() string {
+ version := string(constants.DefaultQmdVersion)
var sb strings.Builder
sb.WriteString(" - name: Cache node-llama-cpp binaries\n")
fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache"))
sb.WriteString(" with:\n")
sb.WriteString(" path: ~/.cache/node-llama-cpp/\n")
- sb.WriteString(" key: node-llama-cpp-${{ runner.os }}-${{ runner.arch }}\n")
+ fmt.Fprintf(&sb, " key: node-llama-cpp-%s-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}\n", version)
return sb.String()
}
@@ -125,12 +127,13 @@ func generateQmdNodeLlamaCppCacheStep() string {
// actions/cache/restore (restore-only, no post-save) so the agent job never writes to the
// shared cache — that is the indexing job's responsibility.
func generateQmdModelsCacheRestoreStep() string {
+ version := string(constants.DefaultQmdVersion)
var sb strings.Builder
sb.WriteString(" - name: Restore qmd models cache\n")
fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/restore"))
sb.WriteString(" with:\n")
sb.WriteString(" path: ~/.cache/qmd/models/\n")
- sb.WriteString(" key: qmd-models-${{ runner.os }}\n")
+ fmt.Fprintf(&sb, " key: qmd-models-%s-${{ runner.os }}\n", version)
return sb.String()
}
@@ -138,14 +141,15 @@ func generateQmdModelsCacheRestoreStep() string {
// node-llama-cpp downloaded binaries (~/.cache/node-llama-cpp/) from GitHub Actions cache.
// It uses actions/cache/restore (restore-only, no post-save) so the agent job never writes
// to the shared cache — that is the indexing job's responsibility.
-// The cache key includes both OS and CPU architecture to ensure binary compatibility.
+// The cache key includes the qmd version, OS, CPU architecture, and runner image ID.
func generateQmdNodeLlamaCppCacheRestoreStep() string {
+ version := string(constants.DefaultQmdVersion)
var sb strings.Builder
sb.WriteString(" - name: Restore node-llama-cpp cache\n")
fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/restore"))
sb.WriteString(" with:\n")
sb.WriteString(" path: ~/.cache/node-llama-cpp/\n")
- sb.WriteString(" key: node-llama-cpp-${{ runner.os }}-${{ runner.arch }}\n")
+ fmt.Fprintf(&sb, " key: node-llama-cpp-%s-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}\n", version)
return sb.String()
}
@@ -170,13 +174,13 @@ func generateQmdIndexCacheRestoreExactStep(qmdConfig *QmdToolConfig) string {
// the index built in the indexing job is always persisted to cache and the agent
// job can restore it without needing a separate artifact download on every run.
//
-// The default key format is: gh-aw-qmd-
-// (e.g. "gh-aw-qmd-12345678")
+// The default key format is: gh-aw-qmd--
+// (e.g. "gh-aw-qmd-2.0.1-12345678")
func resolveQmdCacheKey(qmdConfig *QmdToolConfig) string {
if qmdConfig.CacheKey != "" {
return qmdConfig.CacheKey
}
- return "gh-aw-qmd-${{ github.run_id }}"
+ return fmt.Sprintf("gh-aw-qmd-%s-${{ github.run_id }}", string(constants.DefaultQmdVersion))
}
// resolveQmdRestoreKeys returns the restore-keys prefix list for the qmd index cache.
@@ -492,20 +496,27 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig) []string {
// up to 10 minutes for the server to become ready before the agent starts.
//
// The step id is "qmd-server-start" and it emits a "port" output used by downstream steps.
+//
+// The qmd CLI uses INDEX_PATH to locate the pre-built SQLite database.
+// GH_AW_QMD_VERSION is passed to start_qmd_server.sh to pin the npx package version.
func generateQmdStartServerStep(qmdConfig *QmdToolConfig) string {
var sb strings.Builder
sb.WriteString(" - name: Start qmd MCP HTTP server\n")
sb.WriteString(" id: qmd-server-start\n")
sb.WriteString(" env:\n")
fmt.Fprintf(&sb, " GH_AW_QMD_PORT: \"%d\"\n", constants.DefaultQmdPort)
- sb.WriteString(" QMD_CACHE_DIR: /tmp/gh-aw/qmd-index\n")
+ // INDEX_PATH overrides getDefaultDbPath() in the qmd CLI so it reads the
+ // pre-built index restored from cache rather than ~/.cache/qmd/index.sqlite.
+ sb.WriteString(" INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite\n")
+ fmt.Fprintf(&sb, " GH_AW_QMD_VERSION: \"%s\"\n", string(constants.DefaultQmdVersion))
// Disable GPU by default; only enable when the user explicitly opts in.
if !qmdConfig.GPU {
sb.WriteString(" NODE_LLAMA_CPP_GPU: \"false\"\n")
}
sb.WriteString(" run: |\n")
sb.WriteString(" export GH_AW_QMD_PORT\n")
- sb.WriteString(" export QMD_CACHE_DIR\n")
+ sb.WriteString(" export INDEX_PATH\n")
+ sb.WriteString(" export GH_AW_QMD_VERSION\n")
if !qmdConfig.GPU {
sb.WriteString(" export NODE_LLAMA_CPP_GPU\n")
}
From 3ca35f9945f62884553c2d0e989c5a2fb79a8d09 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 17:05:44 +0000
Subject: [PATCH 28/49] fix: change DefaultQmdPort from 3002 to 37842 (less
popular port)
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4756e0ac-9791-4bc1-9a38-d76eb7ee65e3
---
.github/workflows/smoke-codex.lock.yml | 2 +-
pkg/constants/constants.go | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 2f2651bc6e6..e5896d6b2f6 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -441,7 +441,7 @@ jobs:
- name: Start qmd MCP HTTP server
id: qmd-server-start
env:
- GH_AW_QMD_PORT: "3002"
+ GH_AW_QMD_PORT: "37842"
INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
GH_AW_QMD_VERSION: "2.0.1"
NODE_LLAMA_CPP_GPU: "false"
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 8d9857b700b..8998c646589 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -237,7 +237,7 @@ const (
DefaultMCPInspectorPort = 3001
// DefaultQmdPort is the default HTTP port for the qmd MCP server
- DefaultQmdPort = 3002
+ DefaultQmdPort = 37842
// MinNetworkPort is the minimum valid network port number
MinNetworkPort = 1
From 9a53579a938c4e546b8c055aa33f76e6597f4756 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 17:21:51 +0000
Subject: [PATCH 29/49] feat(dev.md): add label_command dev trigger, qmd for
docs+issues, and documentation analysis prompt
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/c8a1219c-5448-41e7-8d59-3f4375bc581d
---
.github/workflows/dev.lock.yml | 239 +++++++++++++++++++++++++++++++--
.github/workflows/dev.md | 34 ++++-
2 files changed, 258 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 76d229a7f3c..9750e0ff541 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -22,29 +22,53 @@
#
# Daily status report for gh-aw project
#
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"8c8abae2e173ed0fcbd79e5003187cf9b17e04ae7fd24f874ccbd71611af6387","agent_id":"copilot"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"7b643e59fbf620ffb86723e5ed64e37d9b2598bfb9876fc405911995a9910c74","agent_id":"copilot"}
name: "Dev"
"on":
+ discussion:
+ types:
+ - labeled
+ issues:
+ types:
+ - labeled
+ pull_request:
+ types:
+ - labeled
schedule:
- cron: "0 9 * * *"
workflow_dispatch:
+ inputs:
+ item_number:
+ description: The number of the issue, pull request, or discussion
+ required: true
+ type: string
permissions: {}
concurrency:
- group: "gh-aw-${{ github.workflow }}"
+ group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}"
run-name: "Dev"
jobs:
activation:
+ needs: pre_activation
+ if: >
+ needs.pre_activation.outputs.activated == 'true' && ((github.event_name == 'issues' || github.event_name == 'pull_request' ||
+ github.event_name == 'discussion') && github.event.label.name == 'dev' || (!(github.event_name == 'issues')) &&
+ (!(github.event_name == 'pull_request')) && (!(github.event_name == 'discussion')))
runs-on: ubuntu-slim
permissions:
contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
outputs:
- comment_id: ""
- comment_repo: ""
+ comment_id: ${{ steps.add-comment.outputs.comment-id }}
+ comment_repo: ${{ steps.add-comment.outputs.comment-repo }}
+ comment_url: ${{ steps.add-comment.outputs.comment-url }}
+ label_command: ${{ steps.remove_trigger_label.outputs.label_name }}
lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
model: ${{ steps.generate_aw_info.outputs.model }}
steps:
@@ -84,6 +108,19 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
await main(core, context);
+ - name: Add eyes reaction for immediate feedback
+ id: react
+ if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_REACTION: "eyes"
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/add_reaction.cjs');
+ await main();
- name: Checkout .github and .agents folders
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -103,6 +140,29 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
await main();
+ - name: Add comment with workflow run link
+ id: add-comment
+ if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_WORKFLOW_NAME: "Dev"
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/add_workflow_run_comment.cjs');
+ await main();
+ - name: Remove trigger label
+ id: remove_trigger_label
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_LABEL_NAMES: '["dev"]'
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/remove_trigger_label.cjs');
+ await main();
- name: Create prompt with built-in context
env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
@@ -124,6 +184,7 @@ jobs:
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/qmd_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_EOF'
@@ -188,6 +249,7 @@ jobs:
GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }}
with:
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
@@ -206,7 +268,8 @@ jobs:
GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER,
GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE
+ GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
+ GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED
}
});
- name: Validate prompt placeholders
@@ -228,15 +291,15 @@ jobs:
retention-days: 1
agent:
- needs: activation
+ needs:
+ - activation
+ - indexing
runs-on: ubuntu-latest
permissions:
contents: read
copilot-requests: write
issues: read
pull-requests: read
- concurrency:
- group: "gh-aw-copilot-${{ github.workflow }}"
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_AW_ASSETS_ALLOWED_EXTS: ""
@@ -312,6 +375,34 @@ jobs:
GH_HOST: github.com
- name: Install AWF binary
run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5
+ - name: Restore qmd index from cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
+ - name: Restore qmd models cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/qmd/models/
+ key: qmd-models-2.0.1-${{ runner.os }}
+ - name: Restore node-llama-cpp cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/node-llama-cpp/
+ key: node-llama-cpp-2.0.1-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}
+ - name: Start qmd MCP HTTP server
+ id: qmd-server-start
+ env:
+ GH_AW_QMD_PORT: "37842"
+ INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
+ GH_AW_QMD_VERSION: "2.0.1"
+ NODE_LLAMA_CPP_GPU: "false"
+ run: |
+ export GH_AW_QMD_PORT
+ export INDEX_PATH
+ export GH_AW_QMD_VERSION
+ export NODE_LLAMA_CPP_GPU
+ bash ${RUNNER_TEMP}/gh-aw/actions/start_qmd_server.sh
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -479,6 +570,7 @@ jobs:
- name: Start MCP Gateway
id: start-mcp-gateway
env:
+ GH_AW_QMD_PORT: ${{ steps.qmd-server-start.outputs.port }}
GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
@@ -501,7 +593,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="copilot"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.20'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_QMD_PORT -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.20'
mkdir -p /home/runner/.copilot
cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
@@ -523,6 +615,10 @@ jobs:
}
}
},
+ "qmd": {
+ "type": "http",
+ "url": "http://host.docker.internal:$GH_AW_QMD_PORT"
+ },
"safeoutputs": {
"type": "http",
"url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
@@ -954,6 +1050,131 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
await main();
+ - name: Update reaction comment with completion status
+ id: conclusion
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
+ GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }}
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_WORKFLOW_NAME: "Dev"
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_DETECTION_CONCLUSION: ${{ needs.agent.outputs.detection_conclusion }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/notify_comment_error.cjs');
+ await main();
+
+ indexing:
+ needs: activation
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ timeout-minutes: 60
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Checkout repository for qmd indexing
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - name: Restore qmd index from cache
+ id: qmd-cache-restore
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
+ restore-keys: |
+ gh-aw-qmd-2.0.1-
+ - name: Cache qmd models
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/qmd/models/
+ key: qmd-models-2.0.1-${{ runner.os }}
+ - name: Cache node-llama-cpp binaries
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/node-llama-cpp/
+ key: node-llama-cpp-2.0.1-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}
+ - name: Setup Node.js for qmd
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: "24"
+ - name: Install @tobilu/qmd SDK
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ run: |
+ npm install --prefix "${{ runner.temp }}/gh-aw/actions" @tobilu/qmd@2.0.1 @actions/github
+ - name: Build qmd index
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ QMD_CONFIG_JSON: |
+ {"dbPath":"/tmp/gh-aw/qmd-index","checkouts":[{"name":"docs","path":"${GITHUB_WORKSPACE}","patterns":["docs/src/**/*.md","docs/src/**/*.mdx"],"context":"gh-aw project documentation"}],"searches":[{"name":"issues","type":"issues","max":500,"tokenEnvVar":"QMD_SEARCH_TOKEN_0"}]}
+ NODE_LLAMA_CPP_GPU: "false"
+ QMD_SEARCH_TOKEN_0: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ github-token: ${{ github.token }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/qmd_index.cjs');
+ await main();
+ - name: Save qmd index to cache
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
+
+ pre_activation:
+ if: >
+ (github.event_name == 'issues' || github.event_name == 'pull_request' || github.event_name == 'discussion') &&
+ github.event.label.name == 'dev' || (!(github.event_name == 'issues')) && (!(github.event_name == 'pull_request')) &&
+ (!(github.event_name == 'discussion'))
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ outputs:
+ activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
+ matched_command: ''
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Check team membership for workflow
+ id: check_membership
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_REQUIRED_ROLES: admin,maintainer,write
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs');
+ await main();
safe_outputs:
needs: agent
diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md
index bec7d5072f5..22d589915e8 100644
--- a/.github/workflows/dev.md
+++ b/.github/workflows/dev.md
@@ -3,6 +3,7 @@ on:
workflow_dispatch:
schedule:
- cron: '0 9 * * *' # Daily at 9 AM UTC
+ label_command: dev
name: Dev
description: Daily status report for gh-aw project
timeout-minutes: 30
@@ -14,6 +15,20 @@ permissions:
issues: read
pull-requests: read
+tools:
+ qmd:
+ checkouts:
+ - name: docs
+ paths:
+ - docs/src/**/*.md
+ - docs/src/**/*.mdx
+ context: "gh-aw project documentation"
+ searches:
+ - name: issues
+ type: issues
+ max: 500
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
safe-outputs:
create-issue:
expires: 7d
@@ -24,14 +39,21 @@ features:
# Daily Status Report
-Generate a daily status report for the gh-aw project.
+Generate a daily status report for the gh-aw project, focusing on documentation quality.
**Requirements:**
-1. Analyze the current state of the repository
-2. Check for recent commits, pull requests, and issues
-3. Identify any potential issues or areas needing attention
-4. Create a comprehensive daily status report
-5. Post the report as an issue with the date in the title
+
+1. **Find documentation problems reported in issues**: Use the `qmd` search tool to query the indexed issues collection for issues that mention documentation bugs, unclear instructions, missing documentation, or incorrect documentation. Look for patterns like "docs", "documentation", "unclear", "wrong", "missing", "broken", "outdated".
+
+2. **Cross-reference with current documentation**: For each documentation problem found in issues, use the `qmd` search tool to query the indexed docs collection to find the relevant documentation section that the issue is referencing or that could answer the question raised.
+
+3. **Compile a report** summarizing:
+ - Issues that report documentation problems (with issue numbers and titles)
+ - The corresponding documentation sections that may need updating
+ - Any issues where the documentation actually already contains the answer (and the issue could be closed with a pointer)
+ - Gaps where no documentation exists for a reported problem
+
+4. Post the report as an issue with the date in the title.
Keep the report informative but concise.
From d64d138771a80943788b4b1bc39f127614a57133 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 18:17:21 +0000
Subject: [PATCH 30/49] Add Node.js 24 setup step before qmd MCP server start
in agent job
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e5bdee6f-6ed8-4a82-b632-cd298cff9933
---
.github/workflows/dev.lock.yml | 4 ++++
.github/workflows/smoke-codex.lock.yml | 4 ++++
pkg/workflow/compiler_yaml_main_job.go | 2 ++
pkg/workflow/qmd.go | 12 ++++++++++++
4 files changed, 22 insertions(+)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 9750e0ff541..7248625f8af 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -390,6 +390,10 @@ jobs:
with:
path: ~/.cache/node-llama-cpp/
key: node-llama-cpp-2.0.1-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}
+ - name: Setup Node.js for qmd MCP server
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: "24"
- name: Start qmd MCP HTTP server
id: qmd-server-start
env:
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index e5896d6b2f6..4bffb0d50e5 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -438,6 +438,10 @@ jobs:
with:
path: ~/.cache/node-llama-cpp/
key: node-llama-cpp-2.0.1-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}
+ - name: Setup Node.js for qmd MCP server
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: "24"
- name: Start qmd MCP HTTP server
id: qmd-server-start
env:
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index b700d9bd514..e374739fddb 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -289,6 +289,8 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
yaml.WriteString(generateQmdModelsCacheRestoreStep())
compilerYamlLog.Print("Adding node-llama-cpp cache restore step (read-only)")
yaml.WriteString(generateQmdNodeLlamaCppCacheRestoreStep())
+ compilerYamlLog.Print("Adding Node.js setup step for qmd MCP server")
+ yaml.WriteString(generateQmdSetupNodeStep())
compilerYamlLog.Print("Adding qmd MCP server start step")
yaml.WriteString(generateQmdStartServerStep(data.QmdConfig))
}
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 433c3239c3a..50678a33e3f 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -489,6 +489,18 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig) []string {
return steps
}
+// generateQmdSetupNodeStep generates the agent job step that sets up Node.js before the
+// qmd MCP server is started. The qmd CLI requires a recent Node.js version (24) to run;
+// the default Node.js on GitHub-hosted runners may be older, so we explicitly configure it.
+func generateQmdSetupNodeStep() string {
+ var sb strings.Builder
+ sb.WriteString(" - name: Setup Node.js for qmd MCP server\n")
+ sb.WriteString(fmt.Sprintf(" uses: %s\n", GetActionPin("actions/setup-node")))
+ sb.WriteString(" with:\n")
+ sb.WriteString(fmt.Sprintf(" node-version: \"%s\"\n", string(constants.DefaultNodeVersion)))
+ return sb.String()
+}
+
// generateQmdStartServerStep generates the agent job step that starts the qmd MCP server
// with HTTP transport. The server is started before the MCP gateway so that node-llama-cpp
// has time to download llama.cpp binaries and embedding model weights if needed — this can
From 78c273384ffba313488103a6e7977c0465077e28 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 18:37:54 +0000
Subject: [PATCH 31/49] fix: remove label_command from dev.md so
workflow_dispatch requires no inputs
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/78734527-c2d1-4744-852b-b8d6253e938d
---
.github/workflows/dev.lock.yml | 128 ++-------------------------------
.github/workflows/dev.md | 1 -
2 files changed, 7 insertions(+), 122 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 7248625f8af..bb210c9b3d5 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -22,53 +22,29 @@
#
# Daily status report for gh-aw project
#
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"7b643e59fbf620ffb86723e5ed64e37d9b2598bfb9876fc405911995a9910c74","agent_id":"copilot"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"1ce591e73ab62ef40e26639ce1c0e9249fd0f41d8482193342b034573c8c0174","agent_id":"copilot"}
name: "Dev"
"on":
- discussion:
- types:
- - labeled
- issues:
- types:
- - labeled
- pull_request:
- types:
- - labeled
schedule:
- cron: "0 9 * * *"
workflow_dispatch:
- inputs:
- item_number:
- description: The number of the issue, pull request, or discussion
- required: true
- type: string
permissions: {}
concurrency:
- group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}"
+ group: "gh-aw-${{ github.workflow }}"
run-name: "Dev"
jobs:
activation:
- needs: pre_activation
- if: >
- needs.pre_activation.outputs.activated == 'true' && ((github.event_name == 'issues' || github.event_name == 'pull_request' ||
- github.event_name == 'discussion') && github.event.label.name == 'dev' || (!(github.event_name == 'issues')) &&
- (!(github.event_name == 'pull_request')) && (!(github.event_name == 'discussion')))
runs-on: ubuntu-slim
permissions:
contents: read
- discussions: write
- issues: write
- pull-requests: write
outputs:
- comment_id: ${{ steps.add-comment.outputs.comment-id }}
- comment_repo: ${{ steps.add-comment.outputs.comment-repo }}
- comment_url: ${{ steps.add-comment.outputs.comment-url }}
- label_command: ${{ steps.remove_trigger_label.outputs.label_name }}
+ comment_id: ""
+ comment_repo: ""
lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
model: ${{ steps.generate_aw_info.outputs.model }}
steps:
@@ -108,19 +84,6 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
await main(core, context);
- - name: Add eyes reaction for immediate feedback
- id: react
- if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_REACTION: "eyes"
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/add_reaction.cjs');
- await main();
- name: Checkout .github and .agents folders
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -140,29 +103,6 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
await main();
- - name: Add comment with workflow run link
- id: add-comment
- if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_WORKFLOW_NAME: "Dev"
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/add_workflow_run_comment.cjs');
- await main();
- - name: Remove trigger label
- id: remove_trigger_label
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_LABEL_NAMES: '["dev"]'
- with:
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/remove_trigger_label.cjs');
- await main();
- name: Create prompt with built-in context
env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
@@ -249,7 +189,6 @@ jobs:
GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
- GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }}
with:
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
@@ -268,8 +207,7 @@ jobs:
GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER,
GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
- GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED
+ GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE
}
});
- name: Validate prompt placeholders
@@ -300,6 +238,8 @@ jobs:
copilot-requests: write
issues: read
pull-requests: read
+ concurrency:
+ group: "gh-aw-copilot-${{ github.workflow }}"
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_AW_ASSETS_ALLOWED_EXTS: ""
@@ -1054,24 +994,6 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
await main();
- - name: Update reaction comment with completion status
- id: conclusion
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
- GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
- GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }}
- GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
- GH_AW_WORKFLOW_NAME: "Dev"
- GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
- GH_AW_DETECTION_CONCLUSION: ${{ needs.agent.outputs.detection_conclusion }}
- with:
- github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/notify_comment_error.cjs');
- await main();
indexing:
needs: activation
@@ -1144,42 +1066,6 @@ jobs:
key: gh-aw-qmd-2.0.1-${{ github.run_id }}
path: /tmp/gh-aw/qmd-index/
- pre_activation:
- if: >
- (github.event_name == 'issues' || github.event_name == 'pull_request' || github.event_name == 'discussion') &&
- github.event.label.name == 'dev' || (!(github.event_name == 'issues')) && (!(github.event_name == 'pull_request')) &&
- (!(github.event_name == 'discussion'))
- runs-on: ubuntu-slim
- permissions:
- contents: read
- outputs:
- activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
- matched_command: ''
- steps:
- - name: Checkout actions folder
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- repository: github/gh-aw
- sparse-checkout: |
- actions
- persist-credentials: false
- - name: Setup Scripts
- uses: ./actions/setup
- with:
- destination: ${{ runner.temp }}/gh-aw/actions
- - name: Check team membership for workflow
- id: check_membership
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
- env:
- GH_AW_REQUIRED_ROLES: admin,maintainer,write
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- script: |
- const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
- setupGlobals(core, github, context, exec, io);
- const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs');
- await main();
-
safe_outputs:
needs: agent
if: (!cancelled()) && needs.agent.result != 'skipped' && needs.agent.outputs.detection_success == 'true'
diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md
index 22d589915e8..a67f6dea4b3 100644
--- a/.github/workflows/dev.md
+++ b/.github/workflows/dev.md
@@ -3,7 +3,6 @@ on:
workflow_dispatch:
schedule:
- cron: '0 9 * * *' # Daily at 9 AM UTC
- label_command: dev
name: Dev
description: Daily status report for gh-aw project
timeout-minutes: 30
From 7ccf408725978d817065fc49e9efd6c90c853423 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 19:30:43 +0000
Subject: [PATCH 32/49] Add label_command back to dev.md (no workflow_dispatch
inputs)
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/f95c8eac-7373-4251-8158-ad5c09d95fe6
---
.github/workflows/dev.lock.yml | 128 +++++++++++++++++++++++++++++++--
.github/workflows/dev.md | 1 +
2 files changed, 122 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index bb210c9b3d5..4bd2e710ca5 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -22,29 +22,53 @@
#
# Daily status report for gh-aw project
#
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"1ce591e73ab62ef40e26639ce1c0e9249fd0f41d8482193342b034573c8c0174","agent_id":"copilot"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"992b8a4df813dfa50732c5bbc1759c89ffb2eb7b5e2c569f214c17011a56970c","agent_id":"copilot"}
name: "Dev"
"on":
+ discussion:
+ types:
+ - labeled
+ issues:
+ types:
+ - labeled
+ pull_request:
+ types:
+ - labeled
schedule:
- cron: "0 9 * * *"
workflow_dispatch:
+ inputs:
+ item_number:
+ description: The number of the issue, pull request, or discussion
+ required: true
+ type: string
permissions: {}
concurrency:
- group: "gh-aw-${{ github.workflow }}"
+ group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}"
run-name: "Dev"
jobs:
activation:
+ needs: pre_activation
+ if: >
+ needs.pre_activation.outputs.activated == 'true' && ((github.event_name == 'issues' || github.event_name == 'pull_request' ||
+ github.event_name == 'discussion') && github.event.label.name == 'dev' || (!(github.event_name == 'issues')) &&
+ (!(github.event_name == 'pull_request')) && (!(github.event_name == 'discussion')))
runs-on: ubuntu-slim
permissions:
contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
outputs:
- comment_id: ""
- comment_repo: ""
+ comment_id: ${{ steps.add-comment.outputs.comment-id }}
+ comment_repo: ${{ steps.add-comment.outputs.comment-repo }}
+ comment_url: ${{ steps.add-comment.outputs.comment-url }}
+ label_command: ${{ steps.remove_trigger_label.outputs.label_name }}
lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
model: ${{ steps.generate_aw_info.outputs.model }}
steps:
@@ -84,6 +108,19 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
await main(core, context);
+ - name: Add eyes reaction for immediate feedback
+ id: react
+ if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_REACTION: "eyes"
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/add_reaction.cjs');
+ await main();
- name: Checkout .github and .agents folders
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -103,6 +140,29 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
await main();
+ - name: Add comment with workflow run link
+ id: add-comment
+ if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_WORKFLOW_NAME: "Dev"
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/add_workflow_run_comment.cjs');
+ await main();
+ - name: Remove trigger label
+ id: remove_trigger_label
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_LABEL_NAMES: '["dev"]'
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/remove_trigger_label.cjs');
+ await main();
- name: Create prompt with built-in context
env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
@@ -189,6 +249,7 @@ jobs:
GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }}
with:
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
@@ -207,7 +268,8 @@ jobs:
GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER,
GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE
+ GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
+ GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED
}
});
- name: Validate prompt placeholders
@@ -238,8 +300,6 @@ jobs:
copilot-requests: write
issues: read
pull-requests: read
- concurrency:
- group: "gh-aw-copilot-${{ github.workflow }}"
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_AW_ASSETS_ALLOWED_EXTS: ""
@@ -994,6 +1054,24 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
await main();
+ - name: Update reaction comment with completion status
+ id: conclusion
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
+ GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }}
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_WORKFLOW_NAME: "Dev"
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_DETECTION_CONCLUSION: ${{ needs.agent.outputs.detection_conclusion }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/notify_comment_error.cjs');
+ await main();
indexing:
needs: activation
@@ -1066,6 +1144,42 @@ jobs:
key: gh-aw-qmd-2.0.1-${{ github.run_id }}
path: /tmp/gh-aw/qmd-index/
+ pre_activation:
+ if: >
+ (github.event_name == 'issues' || github.event_name == 'pull_request' || github.event_name == 'discussion') &&
+ github.event.label.name == 'dev' || (!(github.event_name == 'issues')) && (!(github.event_name == 'pull_request')) &&
+ (!(github.event_name == 'discussion'))
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ outputs:
+ activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
+ matched_command: ''
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Check team membership for workflow
+ id: check_membership
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_REQUIRED_ROLES: admin,maintainer,write
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs');
+ await main();
+
safe_outputs:
needs: agent
if: (!cancelled()) && needs.agent.result != 'skipped' && needs.agent.outputs.detection_success == 'true'
diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md
index a67f6dea4b3..02811962e99 100644
--- a/.github/workflows/dev.md
+++ b/.github/workflows/dev.md
@@ -1,6 +1,7 @@
---
on:
workflow_dispatch:
+ label_command: dev
schedule:
- cron: '0 9 * * *' # Daily at 9 AM UTC
name: Dev
From ca654942b443fc67c473e4e576b8ee032cf97142 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 20:44:36 +0000
Subject: [PATCH 33/49] fix: address review comments and remove mcp-scripts
experimental warning
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/a4d1d75b-4c53-4df8-8632-73b4fc469ab8
---
docs/src/content/docs/reference/qmd.md | 6 +++---
docs/src/content/docs/reference/tools.md | 7 ++++---
pkg/constants/constants.go | 3 ---
pkg/parser/schemas/main_workflow_schema.json | 2 +-
pkg/workflow/compiler.go | 6 ------
pkg/workflow/compiler_activation_job.go | 6 +++---
pkg/workflow/docker.go | 10 ----------
pkg/workflow/qmd.go | 4 ++--
pkg/workflow/tools_types.go | 4 ++--
9 files changed, 15 insertions(+), 33 deletions(-)
diff --git a/docs/src/content/docs/reference/qmd.md b/docs/src/content/docs/reference/qmd.md
index c0e83257565..d39910702d3 100644
--- a/docs/src/content/docs/reference/qmd.md
+++ b/docs/src/content/docs/reference/qmd.md
@@ -11,12 +11,12 @@ import { Aside } from "@astrojs/starlight/components";
The `qmd` tool is experimental and its API may change without notice.
-The `qmd:` tool integrates [tobi/qmd](https://github.com/tobi/qmd) as a built-in MCP server that performs **vector similarity search** over documentation files. The search index is built in the activation job (which already has `contents: read`) and shared with the agent job via a GitHub Actions artifact, so the agent job does not need `contents: read`.
+The `qmd:` tool integrates [tobi/qmd](https://github.com/tobi/qmd) as a built-in MCP server that performs **vector similarity search** over documentation files. The search index is built in a dedicated `indexing` job (which has `contents: read`) and shared with the agent job via `actions/cache`, so the agent job does not need `contents: read`.
## How it works
-1. **Activation job** — installs `@tobilu/qmd`, registers documentation collections from configured checkouts and/or GitHub searches, builds the vector index, and uploads it as the `qmd-index` artifact.
-2. **Agent job** — downloads the `qmd-index` artifact and starts qmd as an MCP server (`npx @tobilu/qmd serve-mcp`). The agent can call the `search` tool to find relevant documentation files by natural language query.
+1. **Indexing job** — installs `@tobilu/qmd`, registers documentation collections from configured checkouts and/or GitHub searches, builds the vector index, and saves it to `actions/cache`.
+2. **Agent job** — restores the qmd cache (index and models) and starts qmd as an MCP server (`qmd mcp --http`). The agent can call the `search` tool to find relevant documentation files by natural language query.
The embedding models used to build and query the index are automatically cached in both jobs via `actions/cache` (keyed by OS at `~/.cache/qmd/models/`), so models are only downloaded once per runner OS.
diff --git a/docs/src/content/docs/reference/tools.md b/docs/src/content/docs/reference/tools.md
index 972d21b69b3..810671e6b28 100644
--- a/docs/src/content/docs/reference/tools.md
+++ b/docs/src/content/docs/reference/tools.md
@@ -102,13 +102,14 @@ See **[Repo Memory Reference](/gh-aw/reference/repo-memory/)** for complete conf
### QMD Documentation Search (`qmd:`) — Experimental
-Build a local vector search index over documentation files and expose it as an MCP search tool. The index is built in the activation job (no `contents: read` needed in the agent job):
+Build a local vector search index over documentation files and expose it as an MCP search tool. The index is built in a dedicated indexing job (no `contents: read` needed in the agent job):
```yaml wrap
tools:
qmd:
- docs:
- - docs/**/*.md
+ checkouts:
+ - paths:
+ - docs/**/*.md
```
See **[QMD Reference](/gh-aw/reference/qmd/)** for complete configuration options, checkout support, GitHub search integration, and cache key usage.
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 1c02d9c1985..014f96eaa73 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -655,9 +655,6 @@ const ActivationArtifactName = "activation"
// APMArtifactName is the artifact name for the APM (Agent Package Manager) bundle.
const APMArtifactName = "apm"
-// QmdArtifactName is the artifact name for the qmd documentation index built in the activation job.
-const QmdArtifactName = "qmd-index"
-
// SafeOutputItemsArtifactName is the artifact name for the safe output items manifest.
// This artifact contains the JSONL manifest of all items created by safe output handlers
// and is uploaded by the safe_outputs job to avoid conflicting with the "agent" artifact
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index 3aa03ddf744..707e50055d0 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -3350,7 +3350,7 @@
"examples": [true, null]
},
"qmd": {
- "description": "qmd documentation search tool (https://github.com/tobi/qmd). Builds a local vector search index during the activation job and mounts a search MCP server in the agent job. The agent job does not need contents:read permission since the index is pre-built.",
+ "description": "qmd documentation search tool (https://github.com/tobi/qmd). Builds a local vector search index in a dedicated indexing job and shares it with the agent job via GitHub Actions cache. The agent job mounts a search MCP server over the pre-built index and does not need contents:read permission.",
"type": "object",
"properties": {
"checkouts": {
diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go
index db76548960c..559d5bdb514 100644
--- a/pkg/workflow/compiler.go
+++ b/pkg/workflow/compiler.go
@@ -249,12 +249,6 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath
c.IncrementWarningCount()
}
- // Emit experimental warning for mcp-scripts feature
- if IsMCPScriptsEnabled(workflowData.MCPScripts, workflowData) {
- fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Using experimental feature: mcp-scripts"))
- c.IncrementWarningCount()
- }
-
// Emit experimental warning for qmd documentation search feature
if workflowData.QmdConfig != nil {
fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Using experimental feature: qmd"))
diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go
index d622e6cfd0f..d78a9aa7ada 100644
--- a/pkg/workflow/compiler_activation_job.go
+++ b/pkg/workflow/compiler_activation_job.go
@@ -485,9 +485,9 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
}
}
- // Generate qmd index steps if the qmd tool is configured.
- // NOTE: qmd indexing is now handled by the separate "indexing" job that depends on activation.
- // That job builds the index and uploads the qmd-index artifact so the agent job can download it.
+ // qmd indexing is handled by the separate "indexing" job that depends on activation.
+ // That job builds the index and saves/restores it via the GitHub Actions cache, and the agent job
+ // restores the index using actions/cache/restore.
// Upload aw_info.json and prompt.txt as the activation artifact for the agent job to download.
// In workflow_call context the artifact is prefixed to avoid name clashes when multiple callers
diff --git a/pkg/workflow/docker.go b/pkg/workflow/docker.go
index b7645b19c71..c1a3ae10bd6 100644
--- a/pkg/workflow/docker.go
+++ b/pkg/workflow/docker.go
@@ -63,16 +63,6 @@ func collectDockerImages(tools map[string]any, workflowData *WorkflowData, actio
}
}
- // Check for qmd tool (uses node:lts-alpine container for the MCP server)
- if _, hasQmd := tools["qmd"]; hasQmd {
- image := constants.DefaultNodeAlpineLTSImage
- if !imageSet[image] {
- images = append(images, image)
- imageSet[image] = true
- dockerLog.Printf("Added qmd MCP server container: %s", image)
- }
- }
-
// Check for agentic-workflows tool
// In dev mode, the image is built locally in the workflow, so don't add to pull list
// In release/script mode, use alpine:latest which needs to be pulled
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 50678a33e3f..0f09a0e6a69 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -172,7 +172,7 @@ func generateQmdIndexCacheRestoreExactStep(qmdConfig *QmdToolConfig) string {
// If the user specified an explicit cache-key, that is returned as-is.
// Otherwise a per-run key is generated using the GitHub workflow run ID so that
// the index built in the indexing job is always persisted to cache and the agent
-// job can restore it without needing a separate artifact download on every run.
+// job can restore it from the cache without needing a separate artifact download on every run.
//
// The default key format is: gh-aw-qmd--
// (e.g. "gh-aw-qmd-2.0.1-12345678")
@@ -546,7 +546,7 @@ func generateQmdStartServerStep(qmdConfig *QmdToolConfig) string {
// 4. Installs @tobilu/qmd and @actions/github and runs qmd_index.cjs via actions/github-script
// 5. Saves the resulting index to GitHub Actions cache
//
-// The agent job declares a needs dependency on this "indexing" job and downloads the artifact.
+// The agent job declares a needs dependency on this "indexing" job and restores the index from cache.
func (c *Compiler) buildQmdIndexingJob(data *WorkflowData) (*Job, error) {
qmdLog.Printf("Building qmd indexing job: checkouts=%d searches=%d cacheKey=%q",
len(data.QmdConfig.Checkouts), len(data.QmdConfig.Searches), data.QmdConfig.CacheKey)
diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go
index 6a134cda44f..9862805d37a 100644
--- a/pkg/workflow/tools_types.go
+++ b/pkg/workflow/tools_types.go
@@ -316,7 +316,7 @@ type PlaywrightToolConfig struct {
// repository via its own checkout configuration.
type QmdDocCollection struct {
// Name is the collection identifier used in the qmd index.
- // Defaults to "docs" for single-collection configs or "docs-" for multiple collections.
+ // Defaults to "docs-" when not provided (e.g. "docs-0", "docs-1").
Name string `yaml:"name,omitempty"`
// Paths is the list of glob patterns for files to include in this collection.
@@ -374,7 +374,7 @@ type QmdSearchEntry struct {
// QmdToolConfig represents the configuration for the qmd documentation search tool.
// qmd (https://github.com/tobi/qmd) provides local vector search over documentation files.
-// The index is built in the activation job and downloaded by the agent job, so no
+// The index is built in a dedicated indexing job and shared via GitHub Actions cache, so no
// contents:read permission is needed in the agent job.
//
// Two sources can contribute to the index:
From 4d91ff0a08943d2e4c699b40c8e88014522edefd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 22:28:12 +0000
Subject: [PATCH 34/49] initial plan
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/81594ef2-0e76-48ee-800f-2315a74b0bf1
---
.github/workflows/dev.lock.yml | 2 +-
.github/workflows/smoke-codex.lock.yml | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 23dac9646d6..4b70aad7a91 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -597,7 +597,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="copilot"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.25'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_QMD_PORT -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.25'
mkdir -p /home/runner/.copilot
cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 8419e07d832..3fee4cf5d09 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -27,7 +27,7 @@
# - shared/gh.md
# - shared/reporting.md
#
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"8ccab7e12d1831d3a6e67bf289dbda8640b9254de4657fc2b2d8cfc3f33fcfb9","agent_id":"codex"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"ee94928302e2744993f1609697de1da08b593abd93e7f3a190d4c0148d049ff3","agent_id":"codex"}
name: "Smoke Codex"
"on":
@@ -879,7 +879,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="codex"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_DEBUG -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.25'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_AW_QMD_PORT -e GH_DEBUG -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.25'
cat > /tmp/gh-aw/mcp-config/config.toml << GH_AW_MCP_CONFIG_EOF
[history]
From 7aba33ff8838c339e5b71faa638ee77614943d54 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 22 Mar 2026 22:52:58 +0000
Subject: [PATCH 35/49] Remove qmd HTTP server script; configure qmd as
gateway-managed container MCP server
- Delete start_qmd_server.sh and remove generateQmdSetupNodeStep/generateQmdStartServerStep
- Update renderQmdTOML and renderQmdMCPConfigWithOptions to use node:24 container with stdio transport
- Gateway starts qmd via npx with /tmp/gh-aw and ${HOME}/.cache/qmd mounts
- Replace GH_AW_QMD_PORT env var with INDEX_PATH/NODE_LLAMA_CPP_GPU in mcp_environment.go
- Remove unused DefaultQmdPort constant and generateQmdNodeLlamaCppCacheRestoreStep function
- Update golden test files (pre-existing mcpg version bump from main merge)
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/81594ef2-0e76-48ee-800f-2315a74b0bf1
---
.github/workflows/dev.lock.yml | 36 ++---
.github/workflows/smoke-codex.lock.yml | 71 +++++----
actions/setup/sh/start_qmd_server.sh | 111 --------------
pkg/constants/constants.go | 3 -
pkg/workflow/compiler_yaml_main_job.go | 12 +-
pkg/workflow/mcp_environment.go | 11 +-
pkg/workflow/mcp_renderer_builtin.go | 144 ++++++++++++++++--
pkg/workflow/qmd.go | 63 --------
.../basic-copilot.golden | 4 +-
.../with-imports.golden | 4 +-
10 files changed, 199 insertions(+), 260 deletions(-)
delete mode 100644 actions/setup/sh/start_qmd_server.sh
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 4b70aad7a91..6ee6c6b507c 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -385,28 +385,6 @@ jobs:
with:
path: ~/.cache/qmd/models/
key: qmd-models-2.0.1-${{ runner.os }}
- - name: Restore node-llama-cpp cache
- uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
- with:
- path: ~/.cache/node-llama-cpp/
- key: node-llama-cpp-2.0.1-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}
- - name: Setup Node.js for qmd MCP server
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- with:
- node-version: "24"
- - name: Start qmd MCP HTTP server
- id: qmd-server-start
- env:
- GH_AW_QMD_PORT: "37842"
- INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
- GH_AW_QMD_VERSION: "2.0.1"
- NODE_LLAMA_CPP_GPU: "false"
- run: |
- export GH_AW_QMD_PORT
- export INDEX_PATH
- export GH_AW_QMD_VERSION
- export NODE_LLAMA_CPP_GPU
- bash ${RUNNER_TEMP}/gh-aw/actions/start_qmd_server.sh
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -574,13 +552,14 @@ jobs:
- name: Start MCP Gateway
id: start-mcp-gateway
env:
- GH_AW_QMD_PORT: ${{ steps.qmd-server-start.outputs.port }}
GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
+ NODE_LLAMA_CPP_GPU: false
run: |
set -eo pipefail
mkdir -p /tmp/gh-aw/mcp-config
@@ -597,7 +576,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="copilot"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_QMD_PORT -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.25'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e INDEX_PATH -e NODE_LLAMA_CPP_GPU -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.25'
mkdir -p /home/runner/.copilot
cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
@@ -620,8 +599,13 @@ jobs:
}
},
"qmd": {
- "type": "http",
- "url": "http://host.docker.internal:$GH_AW_QMD_PORT"
+ "type": "stdio",
+ "container": "node:24",
+ "entrypoint": "npx",
+ "entrypointArgs": ["--yes", "--package", "@tobilu/qmd@2.0.1", "qmd", "mcp"],
+ "args": ["--network", "host"],
+ "mounts": ["/tmp/gh-aw:/tmp/gh-aw:rw", "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"],
+ "env_vars": ["INDEX_PATH", "HOME", "NODE_LLAMA_CPP_GPU"]
},
"safeoutputs": {
"type": "http",
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 3fee4cf5d09..f4b560cb6bb 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -433,28 +433,6 @@ jobs:
with:
path: ~/.cache/qmd/models/
key: qmd-models-2.0.1-${{ runner.os }}
- - name: Restore node-llama-cpp cache
- uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
- with:
- path: ~/.cache/node-llama-cpp/
- key: node-llama-cpp-2.0.1-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}
- - name: Setup Node.js for qmd MCP server
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- with:
- node-version: "24"
- - name: Start qmd MCP HTTP server
- id: qmd-server-start
- env:
- GH_AW_QMD_PORT: "37842"
- INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
- GH_AW_QMD_VERSION: "2.0.1"
- NODE_LLAMA_CPP_GPU: "false"
- run: |
- export GH_AW_QMD_PORT
- export INDEX_PATH
- export GH_AW_QMD_VERSION
- export NODE_LLAMA_CPP_GPU
- bash ${RUNNER_TEMP}/gh-aw/actions/start_qmd_server.sh
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -854,7 +832,6 @@ jobs:
GH_AW_GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_AW_MCP_SCRIPTS_API_KEY: ${{ steps.mcp-scripts-start.outputs.api_key }}
GH_AW_MCP_SCRIPTS_PORT: ${{ steps.mcp-scripts-start.outputs.port }}
- GH_AW_QMD_PORT: ${{ steps.qmd-server-start.outputs.port }}
GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }}
GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
@@ -862,6 +839,8 @@ jobs:
GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
+ NODE_LLAMA_CPP_GPU: false
run: |
set -eo pipefail
mkdir -p /tmp/gh-aw/mcp-config
@@ -879,7 +858,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="codex"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_AW_QMD_PORT -e GH_DEBUG -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.25'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_DEBUG -e INDEX_PATH -e NODE_LLAMA_CPP_GPU -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.25'
cat > /tmp/gh-aw/mcp-config/config.toml << GH_AW_MCP_CONFIG_EOF
[history]
@@ -929,8 +908,24 @@ jobs:
accept = ["*"]
[mcp_servers.qmd]
- type = "http"
- url = "http://host.docker.internal:$GH_AW_QMD_PORT"
+ container = "node:24"
+ entrypoint = "npx"
+ entrypointArgs = [
+ "--yes",
+ "--package",
+ "@tobilu/qmd@2.0.1",
+ "qmd",
+ "mcp",
+ ]
+ args = [
+ "--network",
+ "host",
+ ]
+ mounts = [
+ "/tmp/gh-aw:/tmp/gh-aw:rw",
+ "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw",
+ ]
+ env_vars = ["INDEX_PATH", "HOME", "NODE_LLAMA_CPP_GPU"]
[mcp_servers.safeoutputs]
type = "http"
@@ -1032,8 +1027,28 @@ jobs:
}
},
"qmd": {
- "type": "http",
- "url": "http://host.docker.internal:$GH_AW_QMD_PORT"
+ "container": "node:24",
+ "entrypoint": "npx",
+ "entrypointArgs": [
+ "--yes",
+ "--package",
+ "@tobilu/qmd@2.0.1",
+ "qmd",
+ "mcp"
+ ],
+ "args": [
+ "--network",
+ "host"
+ ],
+ "mounts": [
+ "/tmp/gh-aw:/tmp/gh-aw:rw",
+ "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"
+ ],
+ "env_vars": [
+ "INDEX_PATH",
+ "HOME",
+ "NODE_LLAMA_CPP_GPU"
+ ]
},
"safeoutputs": {
"type": "http",
diff --git a/actions/setup/sh/start_qmd_server.sh b/actions/setup/sh/start_qmd_server.sh
deleted file mode 100644
index 0b6ca192434..00000000000
--- a/actions/setup/sh/start_qmd_server.sh
+++ /dev/null
@@ -1,111 +0,0 @@
-#!/usr/bin/env bash
-# Start qmd MCP HTTP Server
-# This script starts the qmd MCP server with HTTP transport and waits for it to become ready.
-#
-# qmd uses node-llama-cpp to run embedding models. On first use the llama.cpp binary must be
-# downloaded, which can take several minutes. The health probe loop below accounts for this
-# by waiting up to 10 minutes before giving up.
-#
-# Required environment variables:
-# GH_AW_QMD_PORT - Port to listen on (e.g. 3002)
-# GH_AW_QMD_VERSION - @tobilu/qmd package version to use (e.g. 2.0.1)
-# INDEX_PATH - Path to the pre-built qmd index SQLite file
-# (e.g. /tmp/gh-aw/qmd-index/index.sqlite)
-#
-# Optional environment variables:
-# NODE_LLAMA_CPP_GPU - Set to "false" to disable GPU probing (default: "false")
-
-set -e
-
-echo "Starting qmd MCP HTTP server..."
-echo " Port: ${GH_AW_QMD_PORT}"
-echo " Index: ${INDEX_PATH}"
-echo " GPU: ${NODE_LLAMA_CPP_GPU:-auto}"
-
-# Ensure logs directory exists
-mkdir -p /tmp/gh-aw/mcp-logs/qmd
-
-# Create initial log file for artifact upload
-{
- echo "qmd MCP HTTP Server Log"
- echo "Start time: $(date)"
- echo "==========================================="
- echo ""
-} > /tmp/gh-aw/mcp-logs/qmd/server.log
-
-# Start the qmd MCP server with HTTP transport in the background.
-# INDEX_PATH tells qmd where the pre-built vector index SQLite file lives.
-# NODE_LLAMA_CPP_GPU controls GPU probing; "false" disables it on CPU runners.
-INDEX_PATH="${INDEX_PATH}" \
-NODE_LLAMA_CPP_GPU="${NODE_LLAMA_CPP_GPU:-false}" \
- npx --package="@tobilu/qmd@${GH_AW_QMD_VERSION}" qmd mcp --http --port "${GH_AW_QMD_PORT}" \
- >> /tmp/gh-aw/mcp-logs/qmd/server.log 2>&1 &
-
-SERVER_PID=$!
-echo "Started qmd MCP server with PID ${SERVER_PID}"
-
-# Wait for the server to become ready.
-# A long timeout (10 minutes = 600 seconds) is used because node-llama-cpp may download
-# llama.cpp binaries and embedding model weights on the first run.
-TIMEOUT_SECONDS=600
-RETRY_DELAY=1
-MAX_ATTEMPTS=$((TIMEOUT_SECONDS / RETRY_DELAY))
-ATTEMPT=0
-HEALTH_START=$(date +%s%3N)
-
-echo "Waiting for qmd MCP server to become ready (timeout: ${TIMEOUT_SECONDS}s)..."
-
-while [ "${ATTEMPT}" -lt "${MAX_ATTEMPTS}" ]; do
- ATTEMPT=$((ATTEMPT + 1))
-
- # Abort if the server process has already exited
- if ! kill -0 "${SERVER_PID}" 2>/dev/null; then
- echo "ERROR: qmd MCP server process (PID ${SERVER_PID}) has exited unexpectedly"
- echo "=== Server log ==="
- cat /tmp/gh-aw/mcp-logs/qmd/server.log
- exit 1
- fi
-
- # Poll the health endpoint
- if curl -s -f --max-time 2 --connect-timeout 1 \
- "http://localhost:${GH_AW_QMD_PORT}/health" > /dev/null 2>&1; then
- ELAPSED_MS=$(( $(date +%s%3N) - HEALTH_START ))
- echo "qmd MCP server is ready after ${ELAPSED_MS}ms (attempt ${ATTEMPT}/${MAX_ATTEMPTS})"
- echo ""
- echo "::group::qmd server startup log"
- cat /tmp/gh-aw/mcp-logs/qmd/server.log
- echo "::endgroup::"
- break
- fi
-
- # Log progress every 30 seconds
- if [ $(( ATTEMPT % 30 )) -eq 0 ]; then
- ELAPSED_SEC=$(( ($(date +%s%3N) - HEALTH_START) / 1000 ))
- echo "Still waiting... (attempt ${ATTEMPT}/${MAX_ATTEMPTS}, ${ELAPSED_SEC}s elapsed)"
- # Show last few log lines for diagnostics
- tail -5 /tmp/gh-aw/mcp-logs/qmd/server.log 2>/dev/null || true
- fi
-
- if [ "${ATTEMPT}" -eq "${MAX_ATTEMPTS}" ]; then
- echo "ERROR: qmd MCP server failed to respond within ${TIMEOUT_SECONDS}s"
- echo "Last HTTP check on http://localhost:${GH_AW_QMD_PORT}/health failed."
- echo ""
- echo "=== Server log (full) ==="
- cat /tmp/gh-aw/mcp-logs/qmd/server.log
- echo ""
- echo "Checking port availability:"
- ss -tuln 2>/dev/null | grep "${GH_AW_QMD_PORT}" || \
- netstat -tuln 2>/dev/null | grep "${GH_AW_QMD_PORT}" || \
- echo "Port ${GH_AW_QMD_PORT} not listed (ss/netstat not available)"
- exit 1
- fi
-
- sleep "${RETRY_DELAY}"
-done
-
-# Write the port to GITHUB_OUTPUT so downstream steps can reference it
-{
- echo "port=${GH_AW_QMD_PORT}"
-} >> "${GITHUB_OUTPUT}"
-
-echo "qmd MCP server started successfully on port ${GH_AW_QMD_PORT}"
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 95e5d658f85..49eaedc2d3f 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -236,9 +236,6 @@ const (
// DefaultMCPInspectorPort is the default port for the MCP inspector (safe-outputs server)
DefaultMCPInspectorPort = 3001
- // DefaultQmdPort is the default HTTP port for the qmd MCP server
- DefaultQmdPort = 37842
-
// MinNetworkPort is the minimum valid network port number
MinNetworkPort = 1
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index e374739fddb..0aaabed031d 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -279,20 +279,18 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
}
}
- // Restore qmd index from cache if qmd tool is configured.
+ // Restore qmd index and models cache if qmd tool is configured.
// The index was built and cached in the indexing job; we restore it using the precise
// cache key so we always get the index from the current workflow run.
+ // The models cache restores the embedding model weights (cross-platform GGUF files) that
+ // the gateway-managed qmd container mounts from ${HOME}/.cache/qmd/.
+ // Note: the node-llama-cpp binary cache is NOT restored here; the container downloads
+ // the appropriate prebuilt binary for its own OS on first use.
if data.QmdConfig != nil {
compilerYamlLog.Print("Adding qmd index exact-key cache restore step")
yaml.WriteString(generateQmdIndexCacheRestoreExactStep(data.QmdConfig))
compilerYamlLog.Print("Adding qmd models cache restore step (read-only)")
yaml.WriteString(generateQmdModelsCacheRestoreStep())
- compilerYamlLog.Print("Adding node-llama-cpp cache restore step (read-only)")
- yaml.WriteString(generateQmdNodeLlamaCppCacheRestoreStep())
- compilerYamlLog.Print("Adding Node.js setup step for qmd MCP server")
- yaml.WriteString(generateQmdSetupNodeStep())
- compilerYamlLog.Print("Adding qmd MCP server start step")
- yaml.WriteString(generateQmdStartServerStep(data.QmdConfig))
}
// GH_AW_SAFE_OUTPUTS is now set at job level, no setup step needed
diff --git a/pkg/workflow/mcp_environment.go b/pkg/workflow/mcp_environment.go
index 94bfa9af70a..575ad2fd073 100644
--- a/pkg/workflow/mcp_environment.go
+++ b/pkg/workflow/mcp_environment.go
@@ -20,6 +20,7 @@
// - Safe Outputs: GH_AW_SAFE_OUTPUTS_*, GH_AW_ASSETS_*
// - MCP Scripts: GH_AW_MCP_SCRIPTS_PORT, GH_AW_MCP_SCRIPTS_API_KEY
// - Serena: GH_AW_SERENA_PORT (local mode only)
+// - qmd: INDEX_PATH, NODE_LLAMA_CPP_GPU (forwarded to the gateway-managed container)
// - Playwright: Secrets from custom args expressions
// - HTTP MCP: Custom secrets from headers and env sections
//
@@ -124,10 +125,14 @@ func collectMCPEnvironmentVariables(tools map[string]any, mcpTools []string, wor
envVars["GH_AW_SAFE_OUTPUTS_API_KEY"] = "${{ steps.safe-outputs-start.outputs.api_key }}"
}
- // Add qmd server port env var if qmd tool is configured.
- // The port is emitted by the "Start qmd MCP HTTP server" step (id: qmd-server-start).
+ // Add qmd env vars if qmd tool is configured.
+ // INDEX_PATH tells the containerized qmd MCP server where to find the pre-built SQLite index.
+ // NODE_LLAMA_CPP_GPU controls GPU probing in node-llama-cpp inside the container.
if workflowData != nil && workflowData.QmdConfig != nil {
- envVars["GH_AW_QMD_PORT"] = "${{ steps.qmd-server-start.outputs.port }}"
+ envVars["INDEX_PATH"] = "/tmp/gh-aw/qmd-index/index.sqlite"
+ if !workflowData.QmdConfig.GPU {
+ envVars["NODE_LLAMA_CPP_GPU"] = "false"
+ }
}
// Check for agentic-workflows GITHUB_TOKEN
diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go
index 2f1cff60979..d07b9a8ea82 100644
--- a/pkg/workflow/mcp_renderer_builtin.go
+++ b/pkg/workflow/mcp_renderer_builtin.go
@@ -74,8 +74,8 @@ func (r *MCPConfigRendererUnified) renderPlaywrightTOML(yaml *strings.Builder, p
}
// RenderQmdMCP generates the qmd documentation search MCP server configuration.
-// qmd uses HTTP transport (qmd mcp --http) to serve the pre-built index over a local port.
-// The qmd server is started before the MCP gateway and the agent connects via HTTP.
+// qmd runs as a containerized stdio MCP server started by the gateway, with the
+// pre-built index and embedding models mounted from the host via Actions cache.
func (r *MCPConfigRendererUnified) RenderQmdMCP(yaml *strings.Builder, qmdTool any) {
mcpRendererLog.Printf("Rendering qmd MCP: format=%s, inline_args=%t", r.options.Format, r.options.InlineArgs)
@@ -88,27 +88,141 @@ func (r *MCPConfigRendererUnified) RenderQmdMCP(yaml *strings.Builder, qmdTool a
renderQmdMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs)
}
-// renderQmdTOML generates qmd MCP configuration in TOML format using HTTP transport.
-// The qmd MCP server is started separately in the agent job by start_qmd_server.sh and
-// listens on GH_AW_QMD_PORT. Using HTTP transport (instead of stdio+container) allows
-// the server to boot once and be probed for health before the gateway starts.
+// renderQmdTOML generates qmd MCP configuration in TOML format using a containerized stdio server.
+// The gateway starts the container, mounting the pre-built index (/tmp/gh-aw/qmd-index/) and
+// embedding models (${HOME}/.cache/qmd/) from the host. INDEX_PATH and HOME env vars are
+// forwarded to the container so qmd and node-llama-cpp locate the correct files.
func (r *MCPConfigRendererUnified) renderQmdTOML(yaml *strings.Builder) {
- mcpRendererBuiltinLog.Print("Rendering qmd MCP in TOML format (HTTP transport)")
+ mcpRendererBuiltinLog.Print("Rendering qmd MCP in TOML format (container stdio)")
+
+ version := string(constants.DefaultQmdVersion)
yaml.WriteString(" \n")
yaml.WriteString(" [mcp_servers.qmd]\n")
- yaml.WriteString(" type = \"http\"\n")
- yaml.WriteString(" url = \"http://host.docker.internal:$GH_AW_QMD_PORT\"\n")
+ yaml.WriteString(" container = \"node:24\"\n")
+ yaml.WriteString(" entrypoint = \"npx\"\n")
+ yaml.WriteString(" entrypointArgs = [\n")
+ yaml.WriteString(" \"--yes\",\n")
+ yaml.WriteString(" \"--package\",\n")
+ yaml.WriteString(" \"@tobilu/qmd@" + version + "\",\n")
+ yaml.WriteString(" \"qmd\",\n")
+ yaml.WriteString(" \"mcp\",\n")
+ yaml.WriteString(" ]\n")
+ yaml.WriteString(" args = [\n")
+ yaml.WriteString(" \"--network\",\n")
+ yaml.WriteString(" \"host\",\n")
+ yaml.WriteString(" ]\n")
+ // Mount the qmd index (under /tmp/gh-aw/) and the embedding models cache.
+ // The node-llama-cpp binary cache is not mounted; the container downloads the
+ // appropriate prebuilt binary for its own OS on first use.
+ yaml.WriteString(" mounts = [\n")
+ yaml.WriteString(" \"/tmp/gh-aw:/tmp/gh-aw:rw\",\n")
+ yaml.WriteString(" \"${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw\",\n")
+ yaml.WriteString(" ]\n")
+ // Forward INDEX_PATH (location of the SQLite index) and HOME (so node-llama-cpp
+ // and qmd resolve ~/.cache/ paths correctly inside the container).
+ // NODE_LLAMA_CPP_GPU is forwarded so GPU probing can be disabled on CPU-only runners.
+ yaml.WriteString(" env_vars = [\"INDEX_PATH\", \"HOME\", \"NODE_LLAMA_CPP_GPU\"]\n")
}
// renderQmdMCPConfigWithOptions generates the qmd MCP server configuration in JSON format.
-// qmd is exposed via HTTP transport — the server was started (and health-probed) before
-// the gateway by the "Start qmd MCP HTTP server" step.
-// _includeCopilotFields and _inlineArgs are unused after the HTTP transport migration.
-func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, _includeCopilotFields bool, _inlineArgs bool) {
+// qmd uses a containerized stdio server started by the MCP gateway, with mounts for
+// the pre-built index and embedding models.
+func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, inlineArgs bool) {
+ version := string(constants.DefaultQmdVersion)
+ qmdArgs := []string{"--yes", "--package", "@tobilu/qmd@" + version, "qmd", "mcp"}
+ dockerArgs := []string{"--network", "host"}
+ mounts := []string{"/tmp/gh-aw:/tmp/gh-aw:rw", "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"}
+ envVars := []string{"INDEX_PATH", "HOME", "NODE_LLAMA_CPP_GPU"}
+
yaml.WriteString(" \"qmd\": {\n")
- yaml.WriteString(" \"type\": \"http\",\n")
- yaml.WriteString(" \"url\": \"http://host.docker.internal:$GH_AW_QMD_PORT\"\n")
+
+ if includeCopilotFields {
+ yaml.WriteString(" \"type\": \"stdio\",\n")
+ }
+
+ yaml.WriteString(" \"container\": \"node:24\",\n")
+ yaml.WriteString(" \"entrypoint\": \"npx\",\n")
+
+ if inlineArgs {
+ // Entrypoint args inline
+ yaml.WriteString(" \"entrypointArgs\": [")
+ for i, arg := range qmdArgs {
+ if i > 0 {
+ yaml.WriteString(", ")
+ }
+ yaml.WriteString("\"" + arg + "\"")
+ }
+ yaml.WriteString("],\n")
+ // Docker args inline
+ yaml.WriteString(" \"args\": [")
+ for i, arg := range dockerArgs {
+ if i > 0 {
+ yaml.WriteString(", ")
+ }
+ yaml.WriteString("\"" + arg + "\"")
+ }
+ yaml.WriteString("],\n")
+ // Mounts inline
+ yaml.WriteString(" \"mounts\": [")
+ for i, m := range mounts {
+ if i > 0 {
+ yaml.WriteString(", ")
+ }
+ yaml.WriteString("\"" + m + "\"")
+ }
+ yaml.WriteString("],\n")
+ // Env vars inline
+ yaml.WriteString(" \"env_vars\": [")
+ for i, ev := range envVars {
+ if i > 0 {
+ yaml.WriteString(", ")
+ }
+ yaml.WriteString("\"" + ev + "\"")
+ }
+ yaml.WriteString("]\n")
+ } else {
+ // Entrypoint args multi-line
+ yaml.WriteString(" \"entrypointArgs\": [\n")
+ for i, arg := range qmdArgs {
+ if i < len(qmdArgs)-1 {
+ yaml.WriteString(" \"" + arg + "\",\n")
+ } else {
+ yaml.WriteString(" \"" + arg + "\"\n")
+ }
+ }
+ yaml.WriteString(" ],\n")
+ // Docker args multi-line
+ yaml.WriteString(" \"args\": [\n")
+ for i, arg := range dockerArgs {
+ if i < len(dockerArgs)-1 {
+ yaml.WriteString(" \"" + arg + "\",\n")
+ } else {
+ yaml.WriteString(" \"" + arg + "\"\n")
+ }
+ }
+ yaml.WriteString(" ],\n")
+ // Mounts multi-line
+ yaml.WriteString(" \"mounts\": [\n")
+ for i, m := range mounts {
+ if i < len(mounts)-1 {
+ yaml.WriteString(" \"" + m + "\",\n")
+ } else {
+ yaml.WriteString(" \"" + m + "\"\n")
+ }
+ }
+ yaml.WriteString(" ],\n")
+ // Env vars multi-line
+ yaml.WriteString(" \"env_vars\": [\n")
+ for i, ev := range envVars {
+ if i < len(envVars)-1 {
+ yaml.WriteString(" \"" + ev + "\",\n")
+ } else {
+ yaml.WriteString(" \"" + ev + "\"\n")
+ }
+ }
+ yaml.WriteString(" ]\n")
+ }
if isLast {
yaml.WriteString(" }\n")
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 0f09a0e6a69..e2b533db2e7 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -137,22 +137,6 @@ func generateQmdModelsCacheRestoreStep() string {
return sb.String()
}
-// generateQmdNodeLlamaCppCacheRestoreStep generates a read-only step that restores the
-// node-llama-cpp downloaded binaries (~/.cache/node-llama-cpp/) from GitHub Actions cache.
-// It uses actions/cache/restore (restore-only, no post-save) so the agent job never writes
-// to the shared cache — that is the indexing job's responsibility.
-// The cache key includes the qmd version, OS, CPU architecture, and runner image ID.
-func generateQmdNodeLlamaCppCacheRestoreStep() string {
- version := string(constants.DefaultQmdVersion)
- var sb strings.Builder
- sb.WriteString(" - name: Restore node-llama-cpp cache\n")
- fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/cache/restore"))
- sb.WriteString(" with:\n")
- sb.WriteString(" path: ~/.cache/node-llama-cpp/\n")
- fmt.Fprintf(&sb, " key: node-llama-cpp-%s-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}\n", version)
- return sb.String()
-}
-
// generateQmdIndexCacheRestoreExactStep generates a read-only restore step for the agent job
// that restores the qmd search index from Actions cache using the PRECISE cache key.
// No restore-keys fallback is used — the agent job must get the exact index that the
@@ -489,53 +473,6 @@ func generateQmdIndexSteps(qmdConfig *QmdToolConfig) []string {
return steps
}
-// generateQmdSetupNodeStep generates the agent job step that sets up Node.js before the
-// qmd MCP server is started. The qmd CLI requires a recent Node.js version (24) to run;
-// the default Node.js on GitHub-hosted runners may be older, so we explicitly configure it.
-func generateQmdSetupNodeStep() string {
- var sb strings.Builder
- sb.WriteString(" - name: Setup Node.js for qmd MCP server\n")
- sb.WriteString(fmt.Sprintf(" uses: %s\n", GetActionPin("actions/setup-node")))
- sb.WriteString(" with:\n")
- sb.WriteString(fmt.Sprintf(" node-version: \"%s\"\n", string(constants.DefaultNodeVersion)))
- return sb.String()
-}
-
-// generateQmdStartServerStep generates the agent job step that starts the qmd MCP server
-// with HTTP transport. The server is started before the MCP gateway so that node-llama-cpp
-// has time to download llama.cpp binaries and embedding model weights if needed — this can
-// take several minutes on the first run. A health probe loop in start_qmd_server.sh waits
-// up to 10 minutes for the server to become ready before the agent starts.
-//
-// The step id is "qmd-server-start" and it emits a "port" output used by downstream steps.
-//
-// The qmd CLI uses INDEX_PATH to locate the pre-built SQLite database.
-// GH_AW_QMD_VERSION is passed to start_qmd_server.sh to pin the npx package version.
-func generateQmdStartServerStep(qmdConfig *QmdToolConfig) string {
- var sb strings.Builder
- sb.WriteString(" - name: Start qmd MCP HTTP server\n")
- sb.WriteString(" id: qmd-server-start\n")
- sb.WriteString(" env:\n")
- fmt.Fprintf(&sb, " GH_AW_QMD_PORT: \"%d\"\n", constants.DefaultQmdPort)
- // INDEX_PATH overrides getDefaultDbPath() in the qmd CLI so it reads the
- // pre-built index restored from cache rather than ~/.cache/qmd/index.sqlite.
- sb.WriteString(" INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite\n")
- fmt.Fprintf(&sb, " GH_AW_QMD_VERSION: \"%s\"\n", string(constants.DefaultQmdVersion))
- // Disable GPU by default; only enable when the user explicitly opts in.
- if !qmdConfig.GPU {
- sb.WriteString(" NODE_LLAMA_CPP_GPU: \"false\"\n")
- }
- sb.WriteString(" run: |\n")
- sb.WriteString(" export GH_AW_QMD_PORT\n")
- sb.WriteString(" export INDEX_PATH\n")
- sb.WriteString(" export GH_AW_QMD_VERSION\n")
- if !qmdConfig.GPU {
- sb.WriteString(" export NODE_LLAMA_CPP_GPU\n")
- }
- sb.WriteString(" bash ${RUNNER_TEMP}/gh-aw/actions/start_qmd_server.sh\n")
- return sb.String()
-}
-
// buildQmdIndexingJob builds a standalone "indexing" job that depends on the activation job
// and builds the qmd documentation search index.
//
diff --git a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden
index bd763b50fcd..0489ea5eb0a 100644
--- a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden
+++ b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/basic-copilot.golden
@@ -283,7 +283,7 @@ jobs:
const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');
await determineAutomaticLockdown(github, context, core);
- name: Download container images
- run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.22 ghcr.io/github/github-mcp-server:v0.32.0
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.25 ghcr.io/github/github-mcp-server:v0.32.0
- name: Start MCP Gateway
id: start-mcp-gateway
env:
@@ -306,7 +306,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="copilot"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.22'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.25'
mkdir -p /home/runner/.copilot
cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
diff --git a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden
index 16c23ec0ccc..1f5b47eb894 100644
--- a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden
+++ b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/with-imports.golden
@@ -286,7 +286,7 @@ jobs:
const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');
await determineAutomaticLockdown(github, context, core);
- name: Download container images
- run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.22 ghcr.io/github/github-mcp-server:v0.32.0
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.25 ghcr.io/github/github-mcp-server:v0.32.0
- name: Start MCP Gateway
id: start-mcp-gateway
env:
@@ -309,7 +309,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="copilot"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.22'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.25'
mkdir -p /home/runner/.copilot
cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
From 285c3d779a3e36ab6466662717f91c26546b38f3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 00:01:51 +0000
Subject: [PATCH 36/49] fix: replace env_vars array with env object in qmd MCP
config
The gateway schema requires env as an object (key-value pairs), not
env_vars as an array. This caused the validation error:
additionalProperties 'env_vars' not allowed
- INDEX_PATH and NODE_LLAMA_CPP_GPU use \${VAR} so heredoc does not
expand them; the gateway resolves them from its own environment
- HOME uses ${HOME} (no backslash) so heredoc expands to /home/runner,
since HOME is not passed to the gateway container
- Use sortedMapKeys for deterministic output and to keep keys/values in sync
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4091c5b1-577e-47b1-9500-d16f21a79db9
---
.github/workflows/dev.lock.yml | 2 +-
.github/workflows/smoke-codex.lock.yml | 12 ++++----
pkg/workflow/mcp_renderer_builtin.go | 38 ++++++++++++++++----------
3 files changed, 31 insertions(+), 21 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 6ee6c6b507c..b083af78a40 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -605,7 +605,7 @@ jobs:
"entrypointArgs": ["--yes", "--package", "@tobilu/qmd@2.0.1", "qmd", "mcp"],
"args": ["--network", "host"],
"mounts": ["/tmp/gh-aw:/tmp/gh-aw:rw", "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"],
- "env_vars": ["INDEX_PATH", "HOME", "NODE_LLAMA_CPP_GPU"]
+ "env": {"HOME": "${HOME}", "INDEX_PATH": "\${INDEX_PATH}", "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}"}
},
"safeoutputs": {
"type": "http",
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index f4b560cb6bb..3ce805ccd87 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -925,7 +925,7 @@ jobs:
"/tmp/gh-aw:/tmp/gh-aw:rw",
"${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw",
]
- env_vars = ["INDEX_PATH", "HOME", "NODE_LLAMA_CPP_GPU"]
+ env = { "INDEX_PATH" = "\${INDEX_PATH}", "HOME" = "${HOME}", "NODE_LLAMA_CPP_GPU" = "\${NODE_LLAMA_CPP_GPU}" }
[mcp_servers.safeoutputs]
type = "http"
@@ -1044,11 +1044,11 @@ jobs:
"/tmp/gh-aw:/tmp/gh-aw:rw",
"${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"
],
- "env_vars": [
- "INDEX_PATH",
- "HOME",
- "NODE_LLAMA_CPP_GPU"
- ]
+ "env": {
+ "HOME": "${HOME}",
+ "INDEX_PATH": "\${INDEX_PATH}",
+ "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}"
+ }
},
"safeoutputs": {
"type": "http",
diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go
index d07b9a8ea82..09cfab6ce43 100644
--- a/pkg/workflow/mcp_renderer_builtin.go
+++ b/pkg/workflow/mcp_renderer_builtin.go
@@ -122,7 +122,9 @@ func (r *MCPConfigRendererUnified) renderQmdTOML(yaml *strings.Builder) {
// Forward INDEX_PATH (location of the SQLite index) and HOME (so node-llama-cpp
// and qmd resolve ~/.cache/ paths correctly inside the container).
// NODE_LLAMA_CPP_GPU is forwarded so GPU probing can be disabled on CPU-only runners.
- yaml.WriteString(" env_vars = [\"INDEX_PATH\", \"HOME\", \"NODE_LLAMA_CPP_GPU\"]\n")
+ // Use \${VAR} so the shell heredoc does not expand them; the gateway resolves them.
+ // HOME is not in the gateway env so it is expanded by the heredoc shell instead.
+ yaml.WriteString(" env = { \"INDEX_PATH\" = \"\\${INDEX_PATH}\", \"HOME\" = \"${HOME}\", \"NODE_LLAMA_CPP_GPU\" = \"\\${NODE_LLAMA_CPP_GPU}\" }\n")
}
// renderQmdMCPConfigWithOptions generates the qmd MCP server configuration in JSON format.
@@ -133,7 +135,15 @@ func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCo
qmdArgs := []string{"--yes", "--package", "@tobilu/qmd@" + version, "qmd", "mcp"}
dockerArgs := []string{"--network", "host"}
mounts := []string{"/tmp/gh-aw:/tmp/gh-aw:rw", "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"}
- envVars := []string{"INDEX_PATH", "HOME", "NODE_LLAMA_CPP_GPU"}
+ // env uses \${VAR} so the heredoc shell does not expand INDEX_PATH and NODE_LLAMA_CPP_GPU;
+ // the gateway resolves them from its own environment (passed via -e flags in DOCKER_COMMAND).
+ // HOME is not in the gateway env, so ${HOME} is expanded by the heredoc shell to /home/runner.
+ envValues := map[string]string{
+ "INDEX_PATH": "\\${INDEX_PATH}",
+ "HOME": "${HOME}",
+ "NODE_LLAMA_CPP_GPU": "\\${NODE_LLAMA_CPP_GPU}",
+ }
+ envKeys := sortedMapKeys(envValues)
yaml.WriteString(" \"qmd\": {\n")
@@ -172,15 +182,15 @@ func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCo
yaml.WriteString("\"" + m + "\"")
}
yaml.WriteString("],\n")
- // Env vars inline
- yaml.WriteString(" \"env_vars\": [")
- for i, ev := range envVars {
+ // Env object inline
+ yaml.WriteString(" \"env\": {")
+ for i, key := range envKeys {
if i > 0 {
yaml.WriteString(", ")
}
- yaml.WriteString("\"" + ev + "\"")
+ yaml.WriteString("\"" + key + "\": \"" + envValues[key] + "\"")
}
- yaml.WriteString("]\n")
+ yaml.WriteString("}\n")
} else {
// Entrypoint args multi-line
yaml.WriteString(" \"entrypointArgs\": [\n")
@@ -212,16 +222,16 @@ func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCo
}
}
yaml.WriteString(" ],\n")
- // Env vars multi-line
- yaml.WriteString(" \"env_vars\": [\n")
- for i, ev := range envVars {
- if i < len(envVars)-1 {
- yaml.WriteString(" \"" + ev + "\",\n")
+ // Env object multi-line
+ yaml.WriteString(" \"env\": {\n")
+ for i, key := range envKeys {
+ if i < len(envKeys)-1 {
+ yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\",\n")
} else {
- yaml.WriteString(" \"" + ev + "\"\n")
+ yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\"\n")
}
}
- yaml.WriteString(" ]\n")
+ yaml.WriteString(" }\n")
}
if isLast {
From 1606b6352e55b301ae7fa46b2abaad9b51cd55aa Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 01:06:04 +0000
Subject: [PATCH 37/49] chore: merge origin/main, rebuild, recompile, update
golden files
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/51228c75-5a27-4790-8a79-2df6819091bd
---
.github/workflows/dev.lock.yml | 227 +++++++++++++++++-
.github/workflows/smoke-codex.lock.yml | 136 ++++++++++-
.../smoke-copilot.golden | 4 +-
3 files changed, 353 insertions(+), 14 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 50e7425e666..a167d4c65a5 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -22,29 +22,53 @@
#
# Daily status report for gh-aw project
#
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"8c8abae2e173ed0fcbd79e5003187cf9b17e04ae7fd24f874ccbd71611af6387","agent_id":"copilot"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"992b8a4df813dfa50732c5bbc1759c89ffb2eb7b5e2c569f214c17011a56970c","agent_id":"copilot"}
name: "Dev"
"on":
+ discussion:
+ types:
+ - labeled
+ issues:
+ types:
+ - labeled
+ pull_request:
+ types:
+ - labeled
schedule:
- cron: "0 9 * * *"
workflow_dispatch:
+ inputs:
+ item_number:
+ description: The number of the issue, pull request, or discussion
+ required: true
+ type: string
permissions: {}
concurrency:
- group: "gh-aw-${{ github.workflow }}"
+ group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}"
run-name: "Dev"
jobs:
activation:
+ needs: pre_activation
+ if: >
+ needs.pre_activation.outputs.activated == 'true' && ((github.event_name == 'issues' || github.event_name == 'pull_request' ||
+ github.event_name == 'discussion') && github.event.label.name == 'dev' || (!(github.event_name == 'issues')) &&
+ (!(github.event_name == 'pull_request')) && (!(github.event_name == 'discussion')))
runs-on: ubuntu-slim
permissions:
contents: read
+ discussions: write
+ issues: write
+ pull-requests: write
outputs:
- comment_id: ""
- comment_repo: ""
+ comment_id: ${{ steps.add-comment.outputs.comment-id }}
+ comment_repo: ${{ steps.add-comment.outputs.comment-repo }}
+ comment_url: ${{ steps.add-comment.outputs.comment-url }}
+ label_command: ${{ steps.remove_trigger_label.outputs.label_name }}
lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
model: ${{ steps.generate_aw_info.outputs.model }}
steps:
@@ -84,6 +108,19 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
await main(core, context);
+ - name: Add eyes reaction for immediate feedback
+ id: react
+ if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_REACTION: "eyes"
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/add_reaction.cjs');
+ await main();
- name: Checkout .github and .agents folders
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -103,6 +140,29 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
await main();
+ - name: Add comment with workflow run link
+ id: add-comment
+ if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_WORKFLOW_NAME: "Dev"
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/add_workflow_run_comment.cjs');
+ await main();
+ - name: Remove trigger label
+ id: remove_trigger_label
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_LABEL_NAMES: '["dev"]'
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/remove_trigger_label.cjs');
+ await main();
- name: Create prompt with built-in context
env:
GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
@@ -124,6 +184,7 @@ jobs:
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/qmd_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_EOF'
@@ -188,6 +249,7 @@ jobs:
GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }}
with:
script: |
const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
@@ -206,7 +268,8 @@ jobs:
GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER,
GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
- GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE
+ GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
+ GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED
}
});
- name: Validate prompt placeholders
@@ -228,15 +291,15 @@ jobs:
retention-days: 1
agent:
- needs: activation
+ needs:
+ - activation
+ - indexing
runs-on: ubuntu-latest
permissions:
contents: read
copilot-requests: write
issues: read
pull-requests: read
- concurrency:
- group: "gh-aw-copilot-${{ github.workflow }}"
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_AW_ASSETS_ALLOWED_EXTS: ""
@@ -312,6 +375,16 @@ jobs:
GH_HOST: github.com
- name: Install AWF binary
run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5
+ - name: Restore qmd index from cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
+ - name: Restore qmd models cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/qmd/models/
+ key: qmd-models-2.0.1-${{ runner.os }}
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -485,6 +558,8 @@ jobs:
GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
+ NODE_LLAMA_CPP_GPU: false
run: |
set -eo pipefail
mkdir -p /tmp/gh-aw/mcp-config
@@ -501,7 +576,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="copilot"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.26'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e INDEX_PATH -e NODE_LLAMA_CPP_GPU -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.26'
mkdir -p /home/runner/.copilot
cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
@@ -523,6 +598,15 @@ jobs:
}
}
},
+ "qmd": {
+ "type": "stdio",
+ "container": "node:24",
+ "entrypoint": "npx",
+ "entrypointArgs": ["--yes", "--package", "@tobilu/qmd@2.0.1", "qmd", "mcp"],
+ "args": ["--network", "host"],
+ "mounts": ["/tmp/gh-aw:/tmp/gh-aw:rw", "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"],
+ "env": {"HOME": "${HOME}", "INDEX_PATH": "\${INDEX_PATH}", "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}"}
+ },
"safeoutputs": {
"type": "http",
"url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
@@ -954,6 +1038,131 @@ jobs:
setupGlobals(core, github, context, exec, io);
const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
await main();
+ - name: Update reaction comment with completion status
+ id: conclusion
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
+ GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }}
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_WORKFLOW_NAME: "Dev"
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_DETECTION_CONCLUSION: ${{ needs.agent.outputs.detection_conclusion }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/notify_comment_error.cjs');
+ await main();
+
+ indexing:
+ needs: activation
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ timeout-minutes: 60
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Checkout repository for qmd indexing
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - name: Restore qmd index from cache
+ id: qmd-cache-restore
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
+ restore-keys: |
+ gh-aw-qmd-2.0.1-
+ - name: Cache qmd models
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/qmd/models/
+ key: qmd-models-2.0.1-${{ runner.os }}
+ - name: Cache node-llama-cpp binaries
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/node-llama-cpp/
+ key: node-llama-cpp-2.0.1-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}
+ - name: Setup Node.js for qmd
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: "24"
+ - name: Install @tobilu/qmd SDK
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ run: |
+ npm install --prefix "${{ runner.temp }}/gh-aw/actions" @tobilu/qmd@2.0.1 @actions/github
+ - name: Build qmd index
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ QMD_CONFIG_JSON: |
+ {"dbPath":"/tmp/gh-aw/qmd-index","checkouts":[{"name":"docs","path":"${GITHUB_WORKSPACE}","patterns":["docs/src/**/*.md","docs/src/**/*.mdx"],"context":"gh-aw project documentation"}],"searches":[{"name":"issues","type":"issues","max":500,"tokenEnvVar":"QMD_SEARCH_TOKEN_0"}]}
+ NODE_LLAMA_CPP_GPU: "false"
+ QMD_SEARCH_TOKEN_0: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ github-token: ${{ github.token }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/qmd_index.cjs');
+ await main();
+ - name: Save qmd index to cache
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
+
+ pre_activation:
+ if: >
+ (github.event_name == 'issues' || github.event_name == 'pull_request' || github.event_name == 'discussion') &&
+ github.event.label.name == 'dev' || (!(github.event_name == 'issues')) && (!(github.event_name == 'pull_request')) &&
+ (!(github.event_name == 'discussion'))
+ runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ outputs:
+ activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }}
+ matched_command: ''
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Check team membership for workflow
+ id: check_membership
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GH_AW_REQUIRED_ROLES: admin,maintainer,write
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs');
+ await main();
safe_outputs:
needs: agent
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 0b190f0166c..7a945f053f3 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -27,7 +27,7 @@
# - shared/gh.md
# - shared/reporting.md
#
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"8ccab7e12d1831d3a6e67bf289dbda8640b9254de4657fc2b2d8cfc3f33fcfb9","agent_id":"codex"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"ee94928302e2744993f1609697de1da08b593abd93e7f3a190d4c0148d049ff3","agent_id":"codex"}
name: "Smoke Codex"
"on":
@@ -190,6 +190,7 @@ jobs:
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/playwright_prompt.md"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/qmd_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
cat << 'GH_AW_PROMPT_EOF'
@@ -319,7 +320,9 @@ jobs:
retention-days: 1
agent:
- needs: activation
+ needs:
+ - activation
+ - indexing
runs-on: ubuntu-latest
permissions:
contents: read
@@ -420,6 +423,16 @@ jobs:
run: npm install -g @openai/codex@latest
- name: Install AWF binary
run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.24.5
+ - name: Restore qmd index from cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
+ - name: Restore qmd models cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/qmd/models/
+ key: qmd-models-2.0.1-${{ runner.os }}
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -826,6 +839,8 @@ jobs:
GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
+ NODE_LLAMA_CPP_GPU: false
run: |
set -eo pipefail
mkdir -p /tmp/gh-aw/mcp-config
@@ -843,7 +858,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="codex"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_DEBUG -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.26'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_DEBUG -e INDEX_PATH -e NODE_LLAMA_CPP_GPU -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.26'
cat > /tmp/gh-aw/mcp-config/config.toml << GH_AW_MCP_CONFIG_EOF
[history]
@@ -892,6 +907,26 @@ jobs:
[mcp_servers.playwright."guard-policies".write-sink]
accept = ["*"]
+ [mcp_servers.qmd]
+ container = "node:24"
+ entrypoint = "npx"
+ entrypointArgs = [
+ "--yes",
+ "--package",
+ "@tobilu/qmd@2.0.1",
+ "qmd",
+ "mcp",
+ ]
+ args = [
+ "--network",
+ "host",
+ ]
+ mounts = [
+ "/tmp/gh-aw:/tmp/gh-aw:rw",
+ "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw",
+ ]
+ env = { "INDEX_PATH" = "\${INDEX_PATH}", "HOME" = "${HOME}", "NODE_LLAMA_CPP_GPU" = "\${NODE_LLAMA_CPP_GPU}" }
+
[mcp_servers.safeoutputs]
type = "http"
url = "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT"
@@ -991,6 +1026,30 @@ jobs:
}
}
},
+ "qmd": {
+ "container": "node:24",
+ "entrypoint": "npx",
+ "entrypointArgs": [
+ "--yes",
+ "--package",
+ "@tobilu/qmd@2.0.1",
+ "qmd",
+ "mcp"
+ ],
+ "args": [
+ "--network",
+ "host"
+ ],
+ "mounts": [
+ "/tmp/gh-aw:/tmp/gh-aw:rw",
+ "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"
+ ],
+ "env": {
+ "HOME": "${HOME}",
+ "INDEX_PATH": "\${INDEX_PATH}",
+ "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}"
+ }
+ },
"safeoutputs": {
"type": "http",
"url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
@@ -1454,6 +1513,77 @@ jobs:
const { main } = require('${{ runner.temp }}/gh-aw/actions/notify_comment_error.cjs');
await main();
+ indexing:
+ needs: activation
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ timeout-minutes: 60
+ steps:
+ - name: Checkout actions folder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: github/gh-aw
+ sparse-checkout: |
+ actions
+ persist-credentials: false
+ - name: Setup Scripts
+ uses: ./actions/setup
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ - name: Checkout repository for qmd indexing
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - name: Restore qmd index from cache
+ id: qmd-cache-restore
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
+ restore-keys: |
+ gh-aw-qmd-2.0.1-
+ - name: Cache qmd models
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/qmd/models/
+ key: qmd-models-2.0.1-${{ runner.os }}
+ - name: Cache node-llama-cpp binaries
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/node-llama-cpp/
+ key: node-llama-cpp-2.0.1-${{ runner.os }}-${{ runner.arch }}-${{ runner.imageid }}
+ - name: Setup Node.js for qmd
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: "24"
+ - name: Install @tobilu/qmd SDK
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ run: |
+ npm install --prefix "${{ runner.temp }}/gh-aw/actions" @tobilu/qmd@2.0.1 @actions/github
+ - name: Build qmd index
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ QMD_CONFIG_JSON: |
+ {"dbPath":"/tmp/gh-aw/qmd-index","checkouts":[{"name":"docs","path":"${GITHUB_WORKSPACE}","patterns":["docs/src/**/*.md","docs/src/**/*.mdx"],"context":"gh-aw project documentation"}],"searches":[{"name":"issues","type":"issues","max":500,"tokenEnvVar":"QMD_SEARCH_TOKEN_0"}]}
+ NODE_LLAMA_CPP_GPU: "false"
+ QMD_SEARCH_TOKEN_0: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ github-token: ${{ github.token }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/qmd_index.cjs');
+ await main();
+ - name: Save qmd index to cache
+ if: steps.qmd-cache-restore.outputs.cache-hit != 'true'
+ uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
+
pre_activation:
if: >
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id) &&
diff --git a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden
index 369b9c5892e..c70b5212bc5 100644
--- a/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden
+++ b/pkg/workflow/testdata/wasm_golden/TestWasmGolden_CompileFixtures/smoke-copilot.golden
@@ -409,7 +409,7 @@ jobs:
const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');
await determineAutomaticLockdown(github, context, core);
- name: Download container images
- run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.25 ghcr.io/github/github-mcp-server:v0.32.0 ghcr.io/github/serena-mcp-server:latest mcr.microsoft.com/playwright/mcp
+ run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.24.5 ghcr.io/github/gh-aw-firewall/api-proxy:0.24.5 ghcr.io/github/gh-aw-firewall/squid:0.24.5 ghcr.io/github/gh-aw-mcpg:v0.1.26 ghcr.io/github/github-mcp-server:v0.32.0 ghcr.io/github/serena-mcp-server:latest mcr.microsoft.com/playwright/mcp
- name: Install gh-aw extension
env:
GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -458,7 +458,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="copilot"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.25'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.26'
mkdir -p /home/runner/.copilot
cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
From c90147f3e3c16ed51e8033baf85660a461fedecd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 02:08:33 +0000
Subject: [PATCH 38/49] fix: add NO_COLOR=1 to qmd container env and
guard-policies to qmd MCP config
- Add NO_COLOR=1 to qmd container environment (both TOML and JSON renderers)
to prevent ANSI escape codes from corrupting the JSON-RPC stream
- Add guard-policies support to qmd MCP config in both TOML and JSON formats,
consistent with playwright, serena, mcp-scripts, and agentic-workflows
Fixes gateway error: invalid character '\x1b' looking for beginning of value
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b99aded2-8a95-458f-a171-afa6b8debabf
---
.github/workflows/dev.lock.yml | 9 +++-
.github/workflows/smoke-codex.lock.yml | 17 ++++++-
pkg/workflow/mcp_renderer_builtin.go | 65 +++++++++++++++++++-------
3 files changed, 72 insertions(+), 19 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index a167d4c65a5..636140e20af 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -605,7 +605,14 @@ jobs:
"entrypointArgs": ["--yes", "--package", "@tobilu/qmd@2.0.1", "qmd", "mcp"],
"args": ["--network", "host"],
"mounts": ["/tmp/gh-aw:/tmp/gh-aw:rw", "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"],
- "env": {"HOME": "${HOME}", "INDEX_PATH": "\${INDEX_PATH}", "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}"}
+ "env": {"HOME": "${HOME}", "INDEX_PATH": "\${INDEX_PATH}", "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}", "NO_COLOR": "1"},
+ "guard-policies": {
+ "write-sink": {
+ "accept": [
+ "*"
+ ]
+ }
+ }
},
"safeoutputs": {
"type": "http",
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 7a945f053f3..763088b8c9c 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -925,7 +925,12 @@ jobs:
"/tmp/gh-aw:/tmp/gh-aw:rw",
"${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw",
]
- env = { "INDEX_PATH" = "\${INDEX_PATH}", "HOME" = "${HOME}", "NODE_LLAMA_CPP_GPU" = "\${NODE_LLAMA_CPP_GPU}" }
+ env = { "INDEX_PATH" = "\${INDEX_PATH}", "HOME" = "${HOME}", "NO_COLOR" = "1", "NODE_LLAMA_CPP_GPU" = "\${NODE_LLAMA_CPP_GPU}" }
+
+ [mcp_servers.qmd."guard-policies"]
+
+ [mcp_servers.qmd."guard-policies".write-sink]
+ accept = ["*"]
[mcp_servers.safeoutputs]
type = "http"
@@ -1047,7 +1052,15 @@ jobs:
"env": {
"HOME": "${HOME}",
"INDEX_PATH": "\${INDEX_PATH}",
- "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}"
+ "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}",
+ "NO_COLOR": "1"
+ },
+ "guard-policies": {
+ "write-sink": {
+ "accept": [
+ "*"
+ ]
+ }
}
},
"safeoutputs": {
diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go
index 09cfab6ce43..6451cd6caad 100644
--- a/pkg/workflow/mcp_renderer_builtin.go
+++ b/pkg/workflow/mcp_renderer_builtin.go
@@ -81,11 +81,16 @@ func (r *MCPConfigRendererUnified) RenderQmdMCP(yaml *strings.Builder, qmdTool a
if r.options.Format == "toml" {
r.renderQmdTOML(yaml)
+ // Add guard policies for TOML format as a separate section
+ if len(r.options.WriteSinkGuardPolicies) > 0 {
+ mcpRendererLog.Print("Adding guard-policies to qmd TOML (derived from GitHub guard-policy)")
+ renderGuardPoliciesToml(yaml, r.options.WriteSinkGuardPolicies, "qmd")
+ }
return
}
// JSON format
- renderQmdMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs)
+ renderQmdMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs, r.options.WriteSinkGuardPolicies)
}
// renderQmdTOML generates qmd MCP configuration in TOML format using a containerized stdio server.
@@ -122,15 +127,16 @@ func (r *MCPConfigRendererUnified) renderQmdTOML(yaml *strings.Builder) {
// Forward INDEX_PATH (location of the SQLite index) and HOME (so node-llama-cpp
// and qmd resolve ~/.cache/ paths correctly inside the container).
// NODE_LLAMA_CPP_GPU is forwarded so GPU probing can be disabled on CPU-only runners.
+ // NO_COLOR=1 disables ANSI escape codes in qmd output so the JSON-RPC stream is clean.
// Use \${VAR} so the shell heredoc does not expand them; the gateway resolves them.
// HOME is not in the gateway env so it is expanded by the heredoc shell instead.
- yaml.WriteString(" env = { \"INDEX_PATH\" = \"\\${INDEX_PATH}\", \"HOME\" = \"${HOME}\", \"NODE_LLAMA_CPP_GPU\" = \"\\${NODE_LLAMA_CPP_GPU}\" }\n")
+ yaml.WriteString(" env = { \"INDEX_PATH\" = \"\\${INDEX_PATH}\", \"HOME\" = \"${HOME}\", \"NO_COLOR\" = \"1\", \"NODE_LLAMA_CPP_GPU\" = \"\\${NODE_LLAMA_CPP_GPU}\" }\n")
}
// renderQmdMCPConfigWithOptions generates the qmd MCP server configuration in JSON format.
// qmd uses a containerized stdio server started by the MCP gateway, with mounts for
// the pre-built index and embedding models.
-func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, inlineArgs bool) {
+func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, inlineArgs bool, guardPolicies map[string]any) {
version := string(constants.DefaultQmdVersion)
qmdArgs := []string{"--yes", "--package", "@tobilu/qmd@" + version, "qmd", "mcp"}
dockerArgs := []string{"--network", "host"}
@@ -138,9 +144,11 @@ func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCo
// env uses \${VAR} so the heredoc shell does not expand INDEX_PATH and NODE_LLAMA_CPP_GPU;
// the gateway resolves them from its own environment (passed via -e flags in DOCKER_COMMAND).
// HOME is not in the gateway env, so ${HOME} is expanded by the heredoc shell to /home/runner.
+ // NO_COLOR=1 disables ANSI escape codes in qmd output so the JSON-RPC stream is clean.
envValues := map[string]string{
"INDEX_PATH": "\\${INDEX_PATH}",
"HOME": "${HOME}",
+ "NO_COLOR": "1",
"NODE_LLAMA_CPP_GPU": "\\${NODE_LLAMA_CPP_GPU}",
}
envKeys := sortedMapKeys(envValues)
@@ -183,14 +191,26 @@ func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCo
}
yaml.WriteString("],\n")
// Env object inline
- yaml.WriteString(" \"env\": {")
- for i, key := range envKeys {
- if i > 0 {
- yaml.WriteString(", ")
+ if len(guardPolicies) > 0 {
+ yaml.WriteString(" \"env\": {")
+ for i, key := range envKeys {
+ if i > 0 {
+ yaml.WriteString(", ")
+ }
+ yaml.WriteString("\"" + key + "\": \"" + envValues[key] + "\"")
+ }
+ yaml.WriteString("},\n")
+ renderGuardPoliciesJSON(yaml, guardPolicies, " ")
+ } else {
+ yaml.WriteString(" \"env\": {")
+ for i, key := range envKeys {
+ if i > 0 {
+ yaml.WriteString(", ")
+ }
+ yaml.WriteString("\"" + key + "\": \"" + envValues[key] + "\"")
}
- yaml.WriteString("\"" + key + "\": \"" + envValues[key] + "\"")
+ yaml.WriteString("}\n")
}
- yaml.WriteString("}\n")
} else {
// Entrypoint args multi-line
yaml.WriteString(" \"entrypointArgs\": [\n")
@@ -223,15 +243,28 @@ func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCo
}
yaml.WriteString(" ],\n")
// Env object multi-line
- yaml.WriteString(" \"env\": {\n")
- for i, key := range envKeys {
- if i < len(envKeys)-1 {
- yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\",\n")
- } else {
- yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\"\n")
+ if len(guardPolicies) > 0 {
+ yaml.WriteString(" \"env\": {\n")
+ for i, key := range envKeys {
+ if i < len(envKeys)-1 {
+ yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\",\n")
+ } else {
+ yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\"\n")
+ }
+ }
+ yaml.WriteString(" },\n")
+ renderGuardPoliciesJSON(yaml, guardPolicies, " ")
+ } else {
+ yaml.WriteString(" \"env\": {\n")
+ for i, key := range envKeys {
+ if i < len(envKeys)-1 {
+ yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\",\n")
+ } else {
+ yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\"\n")
+ }
}
+ yaml.WriteString(" }\n")
}
- yaml.WriteString(" }\n")
}
if isLast {
From 3af7267103071cbb893bcc047d1637ba36ef0b63 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 02:42:15 +0000
Subject: [PATCH 39/49] Add CI=1 to qmd container env to suppress
node-llama-cpp progress output
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/de0be902-9461-4165-a6a1-33f4984e886c
---
.github/workflows/dev.lock.yml | 2 +-
.github/workflows/smoke-codex.lock.yml | 3 ++-
pkg/workflow/mcp_renderer_builtin.go | 7 ++++++-
3 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 636140e20af..c61893f9301 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -605,7 +605,7 @@ jobs:
"entrypointArgs": ["--yes", "--package", "@tobilu/qmd@2.0.1", "qmd", "mcp"],
"args": ["--network", "host"],
"mounts": ["/tmp/gh-aw:/tmp/gh-aw:rw", "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"],
- "env": {"HOME": "${HOME}", "INDEX_PATH": "\${INDEX_PATH}", "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}", "NO_COLOR": "1"},
+ "env": {"CI": "1", "HOME": "${HOME}", "INDEX_PATH": "\${INDEX_PATH}", "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}", "NO_COLOR": "1"},
"guard-policies": {
"write-sink": {
"accept": [
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 763088b8c9c..78dcff6848f 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -925,7 +925,7 @@ jobs:
"/tmp/gh-aw:/tmp/gh-aw:rw",
"${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw",
]
- env = { "INDEX_PATH" = "\${INDEX_PATH}", "HOME" = "${HOME}", "NO_COLOR" = "1", "NODE_LLAMA_CPP_GPU" = "\${NODE_LLAMA_CPP_GPU}" }
+ env = { "CI" = "1", "INDEX_PATH" = "\${INDEX_PATH}", "HOME" = "${HOME}", "NO_COLOR" = "1", "NODE_LLAMA_CPP_GPU" = "\${NODE_LLAMA_CPP_GPU}" }
[mcp_servers.qmd."guard-policies"]
@@ -1050,6 +1050,7 @@ jobs:
"${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"
],
"env": {
+ "CI": "1",
"HOME": "${HOME}",
"INDEX_PATH": "\${INDEX_PATH}",
"NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}",
diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go
index 6451cd6caad..a57db3ccdec 100644
--- a/pkg/workflow/mcp_renderer_builtin.go
+++ b/pkg/workflow/mcp_renderer_builtin.go
@@ -128,9 +128,11 @@ func (r *MCPConfigRendererUnified) renderQmdTOML(yaml *strings.Builder) {
// and qmd resolve ~/.cache/ paths correctly inside the container).
// NODE_LLAMA_CPP_GPU is forwarded so GPU probing can be disabled on CPU-only runners.
// NO_COLOR=1 disables ANSI escape codes in qmd output so the JSON-RPC stream is clean.
+ // CI=1 suppresses interactive progress output from node-llama-cpp during inference
+ // (prevents ANSI codes from corrupting the JSON-RPC stdout stream on first tool call).
// Use \${VAR} so the shell heredoc does not expand them; the gateway resolves them.
// HOME is not in the gateway env so it is expanded by the heredoc shell instead.
- yaml.WriteString(" env = { \"INDEX_PATH\" = \"\\${INDEX_PATH}\", \"HOME\" = \"${HOME}\", \"NO_COLOR\" = \"1\", \"NODE_LLAMA_CPP_GPU\" = \"\\${NODE_LLAMA_CPP_GPU}\" }\n")
+ yaml.WriteString(" env = { \"CI\" = \"1\", \"INDEX_PATH\" = \"\\${INDEX_PATH}\", \"HOME\" = \"${HOME}\", \"NO_COLOR\" = \"1\", \"NODE_LLAMA_CPP_GPU\" = \"\\${NODE_LLAMA_CPP_GPU}\" }\n")
}
// renderQmdMCPConfigWithOptions generates the qmd MCP server configuration in JSON format.
@@ -145,7 +147,10 @@ func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCo
// the gateway resolves them from its own environment (passed via -e flags in DOCKER_COMMAND).
// HOME is not in the gateway env, so ${HOME} is expanded by the heredoc shell to /home/runner.
// NO_COLOR=1 disables ANSI escape codes in qmd output so the JSON-RPC stream is clean.
+ // CI=1 suppresses interactive progress output from node-llama-cpp during inference
+ // (prevents ANSI codes from corrupting the JSON-RPC stdout stream on first tool call).
envValues := map[string]string{
+ "CI": "1",
"INDEX_PATH": "\\${INDEX_PATH}",
"HOME": "${HOME}",
"NO_COLOR": "1",
From 8a9cfee834095d0f36911d97efdd87b50236ee74 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 04:47:07 +0000
Subject: [PATCH 40/49] fix: switch qmd MCP to HTTP transport to eliminate ANSI
stdout corruption
The root cause was Llama._callLogger() in node-llama-cpp v3 writing dot-progress
characters directly to process.stdout (bypassing log levels and NO_COLOR=1) during
model loading on first tool call. This corrupted the stdio JSON-RPC stream.
Instead of CI=1 (which disabled qmd LLM operations via LlamaCpp._ciMode check),
switch qmd to HTTP transport (qmd mcp --http). A new 'Start QMD MCP Server' step
starts qmd in a Docker container before the gateway; the MCP protocol travels over
TCP/HTTP, completely independent of qmd's stdout/stderr.
- Add DefaultQmdMCPPort = 8181 constant
- generateQmdStartStep(): Docker start + curl health-check (120s timeout)
- mcp_setup_generator: add qmd start step before gateway
- mcp_renderer_builtin: replace container/stdio config with HTTP URL
- Update RenderQmd signature to pass workflowData for host resolution
- Remove INDEX_PATH/NODE_LLAMA_CPP_GPU from gateway env (now in Docker step)
- Recompile all 177 lock files
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/1769cafa-6727-4bf8-b4ea-44a3f8eb589d
---
.github/workflows/dev.lock.yml | 49 ++++--
.github/workflows/smoke-codex.lock.yml | 86 +++++-----
pkg/constants/constants.go | 6 +
pkg/workflow/codex_mcp.go | 2 +-
pkg/workflow/mcp_environment.go | 15 +-
pkg/workflow/mcp_renderer.go | 2 +-
pkg/workflow/mcp_renderer_builtin.go | 214 +++++--------------------
pkg/workflow/mcp_renderer_helpers.go | 4 +-
pkg/workflow/mcp_renderer_types.go | 2 +-
pkg/workflow/mcp_setup_generator.go | 8 +
pkg/workflow/qmd.go | 65 ++++++++
11 files changed, 212 insertions(+), 241 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index c61893f9301..2931a3c4c27 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -549,6 +549,42 @@ jobs:
bash ${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh
+ - name: Start QMD MCP Server
+ id: qmd-mcp-start
+ env:
+ NO_COLOR: '1'
+ QMD_NODE_LLAMA_CPP_GPU: 'false'
+ run: |
+ # Start qmd MCP server in HTTP mode (avoids stdout/JSON-RPC conflicts).
+ # node-llama-cpp writes dot-progress directly to process.stdout which
+ # would corrupt the stdio JSON-RPC stream; HTTP transport avoids this.
+ docker run -d --rm \
+ --name qmd-mcp-server \
+ --network host \
+ -v /tmp/gh-aw:/tmp/gh-aw:ro \
+ -v "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw" \
+ -e INDEX_PATH=/tmp/gh-aw/qmd-index/index.sqlite \
+ -e "HOME=${HOME}" \
+ -e "NO_COLOR=${NO_COLOR}" \
+ -e "NODE_LLAMA_CPP_GPU=${QMD_NODE_LLAMA_CPP_GPU}" \
+ node:24 \
+ npx --yes --package @tobilu/qmd@2.0.1 qmd mcp --http --port 8181
+
+ # Wait up to 120 s for the server to accept requests
+ echo 'Waiting for QMD MCP server on port 8181...'
+ for i in $(seq 1 60); do
+ if curl -sf http://localhost:8181/health > /dev/null 2>&1; then
+ echo 'QMD MCP server is ready'
+ break
+ fi
+ if [ "$i" -eq 60 ]; then
+ echo 'ERROR: QMD MCP server failed to start within 120 s' >&2
+ docker logs qmd-mcp-server 2>&1 || true
+ exit 1
+ fi
+ sleep 2
+ done
+
- name: Start MCP Gateway
id: start-mcp-gateway
env:
@@ -558,8 +594,6 @@ jobs:
GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
- NODE_LLAMA_CPP_GPU: false
run: |
set -eo pipefail
mkdir -p /tmp/gh-aw/mcp-config
@@ -576,7 +610,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="copilot"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e INDEX_PATH -e NODE_LLAMA_CPP_GPU -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.26'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.26'
mkdir -p /home/runner/.copilot
cat << GH_AW_MCP_CONFIG_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
@@ -599,13 +633,8 @@ jobs:
}
},
"qmd": {
- "type": "stdio",
- "container": "node:24",
- "entrypoint": "npx",
- "entrypointArgs": ["--yes", "--package", "@tobilu/qmd@2.0.1", "qmd", "mcp"],
- "args": ["--network", "host"],
- "mounts": ["/tmp/gh-aw:/tmp/gh-aw:rw", "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"],
- "env": {"CI": "1", "HOME": "${HOME}", "INDEX_PATH": "\${INDEX_PATH}", "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}", "NO_COLOR": "1"},
+ "type": "http",
+ "url": "http://host.docker.internal:8181/mcp",
"guard-policies": {
"write-sink": {
"accept": [
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 78dcff6848f..a325d43f7fd 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -826,6 +826,42 @@ jobs:
bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_scripts_server.sh
+ - name: Start QMD MCP Server
+ id: qmd-mcp-start
+ env:
+ NO_COLOR: '1'
+ QMD_NODE_LLAMA_CPP_GPU: 'false'
+ run: |
+ # Start qmd MCP server in HTTP mode (avoids stdout/JSON-RPC conflicts).
+ # node-llama-cpp writes dot-progress directly to process.stdout which
+ # would corrupt the stdio JSON-RPC stream; HTTP transport avoids this.
+ docker run -d --rm \
+ --name qmd-mcp-server \
+ --network host \
+ -v /tmp/gh-aw:/tmp/gh-aw:ro \
+ -v "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw" \
+ -e INDEX_PATH=/tmp/gh-aw/qmd-index/index.sqlite \
+ -e "HOME=${HOME}" \
+ -e "NO_COLOR=${NO_COLOR}" \
+ -e "NODE_LLAMA_CPP_GPU=${QMD_NODE_LLAMA_CPP_GPU}" \
+ node:24 \
+ npx --yes --package @tobilu/qmd@2.0.1 qmd mcp --http --port 8181
+
+ # Wait up to 120 s for the server to accept requests
+ echo 'Waiting for QMD MCP server on port 8181...'
+ for i in $(seq 1 60); do
+ if curl -sf http://localhost:8181/health > /dev/null 2>&1; then
+ echo 'QMD MCP server is ready'
+ break
+ fi
+ if [ "$i" -eq 60 ]; then
+ echo 'ERROR: QMD MCP server failed to start within 120 s' >&2
+ docker logs qmd-mcp-server 2>&1 || true
+ exit 1
+ fi
+ sleep 2
+ done
+
- name: Start MCP Gateway
id: start-mcp-gateway
env:
@@ -839,8 +875,6 @@ jobs:
GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}
GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}
GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
- INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
- NODE_LLAMA_CPP_GPU: false
run: |
set -eo pipefail
mkdir -p /tmp/gh-aw/mcp-config
@@ -858,7 +892,7 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="codex"
- export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_DEBUG -e INDEX_PATH -e NODE_LLAMA_CPP_GPU -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.26'
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_DEBUG -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.26'
cat > /tmp/gh-aw/mcp-config/config.toml << GH_AW_MCP_CONFIG_EOF
[history]
@@ -908,24 +942,8 @@ jobs:
accept = ["*"]
[mcp_servers.qmd]
- container = "node:24"
- entrypoint = "npx"
- entrypointArgs = [
- "--yes",
- "--package",
- "@tobilu/qmd@2.0.1",
- "qmd",
- "mcp",
- ]
- args = [
- "--network",
- "host",
- ]
- mounts = [
- "/tmp/gh-aw:/tmp/gh-aw:rw",
- "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw",
- ]
- env = { "CI" = "1", "INDEX_PATH" = "\${INDEX_PATH}", "HOME" = "${HOME}", "NO_COLOR" = "1", "NODE_LLAMA_CPP_GPU" = "\${NODE_LLAMA_CPP_GPU}" }
+ type = "http"
+ url = "http://host.docker.internal:8181/mcp"
[mcp_servers.qmd."guard-policies"]
@@ -1032,30 +1050,8 @@ jobs:
}
},
"qmd": {
- "container": "node:24",
- "entrypoint": "npx",
- "entrypointArgs": [
- "--yes",
- "--package",
- "@tobilu/qmd@2.0.1",
- "qmd",
- "mcp"
- ],
- "args": [
- "--network",
- "host"
- ],
- "mounts": [
- "/tmp/gh-aw:/tmp/gh-aw:rw",
- "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"
- ],
- "env": {
- "CI": "1",
- "HOME": "${HOME}",
- "INDEX_PATH": "\${INDEX_PATH}",
- "NODE_LLAMA_CPP_GPU": "\${NODE_LLAMA_CPP_GPU}",
- "NO_COLOR": "1"
- },
+ "type": "http",
+ "url": "http://host.docker.internal:8181/mcp",
"guard-policies": {
"write-sink": {
"accept": [
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 3e567eaaeed..441c2f4c365 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -236,6 +236,12 @@ const (
// DefaultMCPInspectorPort is the default port for the MCP inspector (safe-outputs server)
DefaultMCPInspectorPort = 3001
+ // DefaultQmdMCPPort is the TCP port for the qmd HTTP MCP server started in the agent job.
+ // qmd runs as a Docker container (node:24) with `qmd mcp --http --port PORT`; using HTTP
+ // transport avoids node-llama-cpp's direct process.stdout writes (dot-progress during model
+ // loading) from corrupting the stdio JSON-RPC stream.
+ DefaultQmdMCPPort = 8181
+
// MinNetworkPort is the minimum valid network port number
MinNetworkPort = 1
diff --git a/pkg/workflow/codex_mcp.go b/pkg/workflow/codex_mcp.go
index 9e045fd7de9..950d5fcebda 100644
--- a/pkg/workflow/codex_mcp.go
+++ b/pkg/workflow/codex_mcp.go
@@ -54,7 +54,7 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an
renderer.RenderPlaywrightMCP(yaml, playwrightTool)
case "qmd":
qmdTool := expandedTools["qmd"]
- renderer.RenderQmdMCP(yaml, qmdTool)
+ renderer.RenderQmdMCP(yaml, qmdTool, workflowData)
case "serena":
serenaTool := expandedTools["serena"]
renderer.RenderSerenaMCP(yaml, serenaTool)
diff --git a/pkg/workflow/mcp_environment.go b/pkg/workflow/mcp_environment.go
index 575ad2fd073..fd4039f6446 100644
--- a/pkg/workflow/mcp_environment.go
+++ b/pkg/workflow/mcp_environment.go
@@ -20,7 +20,7 @@
// - Safe Outputs: GH_AW_SAFE_OUTPUTS_*, GH_AW_ASSETS_*
// - MCP Scripts: GH_AW_MCP_SCRIPTS_PORT, GH_AW_MCP_SCRIPTS_API_KEY
// - Serena: GH_AW_SERENA_PORT (local mode only)
-// - qmd: INDEX_PATH, NODE_LLAMA_CPP_GPU (forwarded to the gateway-managed container)
+// - qmd: env vars are set directly in the "Start QMD MCP Server" Docker step (not via gateway)
// - Playwright: Secrets from custom args expressions
// - HTTP MCP: Custom secrets from headers and env sections
//
@@ -125,15 +125,10 @@ func collectMCPEnvironmentVariables(tools map[string]any, mcpTools []string, wor
envVars["GH_AW_SAFE_OUTPUTS_API_KEY"] = "${{ steps.safe-outputs-start.outputs.api_key }}"
}
- // Add qmd env vars if qmd tool is configured.
- // INDEX_PATH tells the containerized qmd MCP server where to find the pre-built SQLite index.
- // NODE_LLAMA_CPP_GPU controls GPU probing in node-llama-cpp inside the container.
- if workflowData != nil && workflowData.QmdConfig != nil {
- envVars["INDEX_PATH"] = "/tmp/gh-aw/qmd-index/index.sqlite"
- if !workflowData.QmdConfig.GPU {
- envVars["NODE_LLAMA_CPP_GPU"] = "false"
- }
- }
+ // qmd env vars (INDEX_PATH, NODE_LLAMA_CPP_GPU) are no longer added to the gateway
+ // environment. qmd now runs as a separate Docker container started by the
+ // "Start QMD MCP Server" step (see qmd.go:generateQmdStartStep), and the gateway
+ // connects to it via HTTP. The env vars are set directly in that Docker start step.
// Check for agentic-workflows GITHUB_TOKEN
if hasAgenticWorkflows {
diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go
index bb8408199d5..c28065597e8 100644
--- a/pkg/workflow/mcp_renderer.go
+++ b/pkg/workflow/mcp_renderer.go
@@ -147,7 +147,7 @@ func RenderJSONMCPConfig(
case "qmd":
qmdTool := tools["qmd"]
if options.Renderers.RenderQmd != nil {
- options.Renderers.RenderQmd(&configBuilder, qmdTool, isLast)
+ options.Renderers.RenderQmd(&configBuilder, qmdTool, isLast, workflowData)
}
case "serena":
serenaTool := tools["serena"]
diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go
index a57db3ccdec..bc1a4be6632 100644
--- a/pkg/workflow/mcp_renderer_builtin.go
+++ b/pkg/workflow/mcp_renderer_builtin.go
@@ -74,13 +74,15 @@ func (r *MCPConfigRendererUnified) renderPlaywrightTOML(yaml *strings.Builder, p
}
// RenderQmdMCP generates the qmd documentation search MCP server configuration.
-// qmd runs as a containerized stdio MCP server started by the gateway, with the
-// pre-built index and embedding models mounted from the host via Actions cache.
-func (r *MCPConfigRendererUnified) RenderQmdMCP(yaml *strings.Builder, qmdTool any) {
+// qmd is started as a separate Docker container (node:24) with HTTP transport by the
+// "Start QMD MCP Server" step before the gateway, so the gateway connects via HTTP.
+// Using HTTP transport avoids node-llama-cpp's direct process.stdout writes (dot-progress
+// during model loading) from corrupting the stdio JSON-RPC stream.
+func (r *MCPConfigRendererUnified) RenderQmdMCP(yaml *strings.Builder, qmdTool any, workflowData *WorkflowData) {
mcpRendererLog.Printf("Rendering qmd MCP: format=%s, inline_args=%t", r.options.Format, r.options.InlineArgs)
if r.options.Format == "toml" {
- r.renderQmdTOML(yaml)
+ r.renderQmdTOML(yaml, workflowData)
// Add guard policies for TOML format as a separate section
if len(r.options.WriteSinkGuardPolicies) > 0 {
mcpRendererLog.Print("Adding guard-policies to qmd TOML (derived from GitHub guard-policy)")
@@ -90,186 +92,56 @@ func (r *MCPConfigRendererUnified) RenderQmdMCP(yaml *strings.Builder, qmdTool a
}
// JSON format
- renderQmdMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs, r.options.WriteSinkGuardPolicies)
+ renderQmdMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, r.options.WriteSinkGuardPolicies, workflowData)
}
-// renderQmdTOML generates qmd MCP configuration in TOML format using a containerized stdio server.
-// The gateway starts the container, mounting the pre-built index (/tmp/gh-aw/qmd-index/) and
-// embedding models (${HOME}/.cache/qmd/) from the host. INDEX_PATH and HOME env vars are
-// forwarded to the container so qmd and node-llama-cpp locate the correct files.
-func (r *MCPConfigRendererUnified) renderQmdTOML(yaml *strings.Builder) {
- mcpRendererBuiltinLog.Print("Rendering qmd MCP in TOML format (container stdio)")
+// resolveQmdHost returns the hostname the gateway should use to reach the qmd HTTP server.
+// qmd starts with --network host, so port DefaultQmdMCPPort is bound on the host network.
+// When the agent sandbox is enabled (default), the gateway runs inside a Docker container
+// with its own network namespace and must reach the host via host.docker.internal.
+// When the agent sandbox is disabled (agent.disabled: true), the gateway also runs on
+// the host, so localhost is sufficient.
+func resolveQmdHost(workflowData *WorkflowData) string {
+ if workflowData != nil && workflowData.SandboxConfig != nil &&
+ workflowData.SandboxConfig.Agent != nil && workflowData.SandboxConfig.Agent.Disabled {
+ return "localhost"
+ }
+ return "host.docker.internal"
+}
+
+// qmdMCPURL returns the full HTTP MCP URL for the qmd server.
+func qmdMCPURL(workflowData *WorkflowData) string {
+ host := resolveQmdHost(workflowData)
+ return "http://" + host + ":" + strconv.Itoa(constants.DefaultQmdMCPPort) + "/mcp"
+}
+
+// renderQmdTOML generates qmd MCP configuration in TOML format using HTTP transport.
+// qmd is started as a separate Docker container before the gateway (see generateQmdStartStep),
+// and the gateway connects to the qmd HTTP MCP server at DefaultQmdMCPPort/mcp.
+func (r *MCPConfigRendererUnified) renderQmdTOML(yaml *strings.Builder, workflowData *WorkflowData) {
+ mcpRendererBuiltinLog.Print("Rendering qmd MCP in TOML format (HTTP transport)")
- version := string(constants.DefaultQmdVersion)
+ url := qmdMCPURL(workflowData)
yaml.WriteString(" \n")
yaml.WriteString(" [mcp_servers.qmd]\n")
- yaml.WriteString(" container = \"node:24\"\n")
- yaml.WriteString(" entrypoint = \"npx\"\n")
- yaml.WriteString(" entrypointArgs = [\n")
- yaml.WriteString(" \"--yes\",\n")
- yaml.WriteString(" \"--package\",\n")
- yaml.WriteString(" \"@tobilu/qmd@" + version + "\",\n")
- yaml.WriteString(" \"qmd\",\n")
- yaml.WriteString(" \"mcp\",\n")
- yaml.WriteString(" ]\n")
- yaml.WriteString(" args = [\n")
- yaml.WriteString(" \"--network\",\n")
- yaml.WriteString(" \"host\",\n")
- yaml.WriteString(" ]\n")
- // Mount the qmd index (under /tmp/gh-aw/) and the embedding models cache.
- // The node-llama-cpp binary cache is not mounted; the container downloads the
- // appropriate prebuilt binary for its own OS on first use.
- yaml.WriteString(" mounts = [\n")
- yaml.WriteString(" \"/tmp/gh-aw:/tmp/gh-aw:rw\",\n")
- yaml.WriteString(" \"${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw\",\n")
- yaml.WriteString(" ]\n")
- // Forward INDEX_PATH (location of the SQLite index) and HOME (so node-llama-cpp
- // and qmd resolve ~/.cache/ paths correctly inside the container).
- // NODE_LLAMA_CPP_GPU is forwarded so GPU probing can be disabled on CPU-only runners.
- // NO_COLOR=1 disables ANSI escape codes in qmd output so the JSON-RPC stream is clean.
- // CI=1 suppresses interactive progress output from node-llama-cpp during inference
- // (prevents ANSI codes from corrupting the JSON-RPC stdout stream on first tool call).
- // Use \${VAR} so the shell heredoc does not expand them; the gateway resolves them.
- // HOME is not in the gateway env so it is expanded by the heredoc shell instead.
- yaml.WriteString(" env = { \"CI\" = \"1\", \"INDEX_PATH\" = \"\\${INDEX_PATH}\", \"HOME\" = \"${HOME}\", \"NO_COLOR\" = \"1\", \"NODE_LLAMA_CPP_GPU\" = \"\\${NODE_LLAMA_CPP_GPU}\" }\n")
+ yaml.WriteString(" type = \"http\"\n")
+ yaml.WriteString(" url = \"" + url + "\"\n")
}
// renderQmdMCPConfigWithOptions generates the qmd MCP server configuration in JSON format.
-// qmd uses a containerized stdio server started by the MCP gateway, with mounts for
-// the pre-built index and embedding models.
-func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, inlineArgs bool, guardPolicies map[string]any) {
- version := string(constants.DefaultQmdVersion)
- qmdArgs := []string{"--yes", "--package", "@tobilu/qmd@" + version, "qmd", "mcp"}
- dockerArgs := []string{"--network", "host"}
- mounts := []string{"/tmp/gh-aw:/tmp/gh-aw:rw", "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw"}
- // env uses \${VAR} so the heredoc shell does not expand INDEX_PATH and NODE_LLAMA_CPP_GPU;
- // the gateway resolves them from its own environment (passed via -e flags in DOCKER_COMMAND).
- // HOME is not in the gateway env, so ${HOME} is expanded by the heredoc shell to /home/runner.
- // NO_COLOR=1 disables ANSI escape codes in qmd output so the JSON-RPC stream is clean.
- // CI=1 suppresses interactive progress output from node-llama-cpp during inference
- // (prevents ANSI codes from corrupting the JSON-RPC stdout stream on first tool call).
- envValues := map[string]string{
- "CI": "1",
- "INDEX_PATH": "\\${INDEX_PATH}",
- "HOME": "${HOME}",
- "NO_COLOR": "1",
- "NODE_LLAMA_CPP_GPU": "\\${NODE_LLAMA_CPP_GPU}",
- }
- envKeys := sortedMapKeys(envValues)
+// qmd uses HTTP transport (server started before the gateway), so only the URL is needed.
+func renderQmdMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, guardPolicies map[string]any, workflowData *WorkflowData) {
+ url := qmdMCPURL(workflowData)
yaml.WriteString(" \"qmd\": {\n")
+ yaml.WriteString(" \"type\": \"http\",\n")
- if includeCopilotFields {
- yaml.WriteString(" \"type\": \"stdio\",\n")
- }
-
- yaml.WriteString(" \"container\": \"node:24\",\n")
- yaml.WriteString(" \"entrypoint\": \"npx\",\n")
-
- if inlineArgs {
- // Entrypoint args inline
- yaml.WriteString(" \"entrypointArgs\": [")
- for i, arg := range qmdArgs {
- if i > 0 {
- yaml.WriteString(", ")
- }
- yaml.WriteString("\"" + arg + "\"")
- }
- yaml.WriteString("],\n")
- // Docker args inline
- yaml.WriteString(" \"args\": [")
- for i, arg := range dockerArgs {
- if i > 0 {
- yaml.WriteString(", ")
- }
- yaml.WriteString("\"" + arg + "\"")
- }
- yaml.WriteString("],\n")
- // Mounts inline
- yaml.WriteString(" \"mounts\": [")
- for i, m := range mounts {
- if i > 0 {
- yaml.WriteString(", ")
- }
- yaml.WriteString("\"" + m + "\"")
- }
- yaml.WriteString("],\n")
- // Env object inline
- if len(guardPolicies) > 0 {
- yaml.WriteString(" \"env\": {")
- for i, key := range envKeys {
- if i > 0 {
- yaml.WriteString(", ")
- }
- yaml.WriteString("\"" + key + "\": \"" + envValues[key] + "\"")
- }
- yaml.WriteString("},\n")
- renderGuardPoliciesJSON(yaml, guardPolicies, " ")
- } else {
- yaml.WriteString(" \"env\": {")
- for i, key := range envKeys {
- if i > 0 {
- yaml.WriteString(", ")
- }
- yaml.WriteString("\"" + key + "\": \"" + envValues[key] + "\"")
- }
- yaml.WriteString("}\n")
- }
+ if len(guardPolicies) > 0 {
+ yaml.WriteString(" \"url\": \"" + url + "\",\n")
+ renderGuardPoliciesJSON(yaml, guardPolicies, " ")
} else {
- // Entrypoint args multi-line
- yaml.WriteString(" \"entrypointArgs\": [\n")
- for i, arg := range qmdArgs {
- if i < len(qmdArgs)-1 {
- yaml.WriteString(" \"" + arg + "\",\n")
- } else {
- yaml.WriteString(" \"" + arg + "\"\n")
- }
- }
- yaml.WriteString(" ],\n")
- // Docker args multi-line
- yaml.WriteString(" \"args\": [\n")
- for i, arg := range dockerArgs {
- if i < len(dockerArgs)-1 {
- yaml.WriteString(" \"" + arg + "\",\n")
- } else {
- yaml.WriteString(" \"" + arg + "\"\n")
- }
- }
- yaml.WriteString(" ],\n")
- // Mounts multi-line
- yaml.WriteString(" \"mounts\": [\n")
- for i, m := range mounts {
- if i < len(mounts)-1 {
- yaml.WriteString(" \"" + m + "\",\n")
- } else {
- yaml.WriteString(" \"" + m + "\"\n")
- }
- }
- yaml.WriteString(" ],\n")
- // Env object multi-line
- if len(guardPolicies) > 0 {
- yaml.WriteString(" \"env\": {\n")
- for i, key := range envKeys {
- if i < len(envKeys)-1 {
- yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\",\n")
- } else {
- yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\"\n")
- }
- }
- yaml.WriteString(" },\n")
- renderGuardPoliciesJSON(yaml, guardPolicies, " ")
- } else {
- yaml.WriteString(" \"env\": {\n")
- for i, key := range envKeys {
- if i < len(envKeys)-1 {
- yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\",\n")
- } else {
- yaml.WriteString(" \"" + key + "\": \"" + envValues[key] + "\"\n")
- }
- }
- yaml.WriteString(" }\n")
- }
+ yaml.WriteString(" \"url\": \"" + url + "\"\n")
}
if isLast {
diff --git a/pkg/workflow/mcp_renderer_helpers.go b/pkg/workflow/mcp_renderer_helpers.go
index 573b96b3078..f7c313d8a47 100644
--- a/pkg/workflow/mcp_renderer_helpers.go
+++ b/pkg/workflow/mcp_renderer_helpers.go
@@ -78,8 +78,8 @@ func buildStandardJSONMCPRenderers(
RenderPlaywright: func(yaml *strings.Builder, playwrightTool any, isLast bool) {
createRenderer(isLast).RenderPlaywrightMCP(yaml, playwrightTool)
},
- RenderQmd: func(yaml *strings.Builder, qmdTool any, isLast bool) {
- createRenderer(isLast).RenderQmdMCP(yaml, qmdTool)
+ RenderQmd: func(yaml *strings.Builder, qmdTool any, isLast bool, workflowData *WorkflowData) {
+ createRenderer(isLast).RenderQmdMCP(yaml, qmdTool, workflowData)
},
RenderSerena: func(yaml *strings.Builder, serenaTool any, isLast bool) {
createRenderer(isLast).RenderSerenaMCP(yaml, serenaTool)
diff --git a/pkg/workflow/mcp_renderer_types.go b/pkg/workflow/mcp_renderer_types.go
index 31b3d42db00..ddd7ecbf63b 100644
--- a/pkg/workflow/mcp_renderer_types.go
+++ b/pkg/workflow/mcp_renderer_types.go
@@ -34,7 +34,7 @@ type RenderCustomMCPToolConfigHandler func(yaml *strings.Builder, toolName strin
type MCPToolRenderers struct {
RenderGitHub func(yaml *strings.Builder, githubTool any, isLast bool, workflowData *WorkflowData)
RenderPlaywright func(yaml *strings.Builder, playwrightTool any, isLast bool)
- RenderQmd func(yaml *strings.Builder, qmdTool any, isLast bool)
+ RenderQmd func(yaml *strings.Builder, qmdTool any, isLast bool, workflowData *WorkflowData)
RenderSerena func(yaml *strings.Builder, serenaTool any, isLast bool)
RenderCacheMemory func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData)
RenderAgenticWorkflows func(yaml *strings.Builder, isLast bool)
diff --git a/pkg/workflow/mcp_setup_generator.go b/pkg/workflow/mcp_setup_generator.go
index 77ba261fd38..be88d53b680 100644
--- a/pkg/workflow/mcp_setup_generator.go
+++ b/pkg/workflow/mcp_setup_generator.go
@@ -467,6 +467,14 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any,
yaml.WriteString(" \n")
}
+ // Start the qmd MCP HTTP server if qmd is configured.
+ // qmd runs in HTTP mode (docker run --network host ... qmd mcp --http) before the gateway
+ // so the gateway connects to it via HTTP. This avoids node-llama-cpp's process.stdout
+ // writes (dot-progress during model loading) from corrupting the stdio JSON-RPC stream.
+ if workflowData != nil && workflowData.QmdConfig != nil {
+ yaml.WriteString(generateQmdStartStep(workflowData.QmdConfig))
+ }
+
// The MCP gateway is always enabled, even when agent sandbox is disabled
// Use the engine's RenderMCPConfig method
yaml.WriteString(" - name: Start MCP Gateway\n")
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index e2b533db2e7..cea85e57090 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -58,6 +58,7 @@
// - tools_types.go: QmdToolConfig, QmdDocCollection, QmdSearchEntry types
// - tools_parser.go: parseQmdTool / parseQmdDocCollection / parseQmdSearchEntry
// - mcp_renderer_builtin.go: RenderQmdMCP method
+// - mcp_setup_generator.go: generateQmdStartStep (agent job HTTP server startup)
// - compiler_jobs.go: buildQmdIndexingJobWrapper
// - compiler_yaml_main_job.go: agent job qmd cache restore
// - actions/setup/js/qmd_index.cjs: JavaScript SDK implementation
@@ -67,6 +68,7 @@ package workflow
import (
"encoding/json"
"fmt"
+ "strconv"
"strings"
"github.com/github/gh-aw/pkg/constants"
@@ -90,6 +92,69 @@ func qmdHasSources(qmdConfig *QmdToolConfig) bool {
return len(qmdConfig.Checkouts) > 0 || len(qmdConfig.Searches) > 0
}
+// generateQmdStartStep generates a GitHub Actions step that starts the qmd MCP server
+// in HTTP mode as a background Docker container before the MCP gateway.
+//
+// Using HTTP transport (qmd mcp --http) avoids node-llama-cpp's direct process.stdout writes
+// (e.g. dot-progress characters during model loading) from being mixed into the stdio
+// JSON-RPC stream and causing "invalid character '\x1b' looking for beginning of value"
+// parse errors in the gateway. With HTTP transport the MCP protocol travels over TCP, so
+// qmd's stdout/stderr are completely independent of the protocol channel.
+//
+// The container mounts:
+// - /tmp/gh-aw (read-only): qmd index SQLite file restored by the cache-restore step
+// - ${HOME}/.cache/qmd (read-write): embedding model GGUF files and node-llama-cpp state
+//
+// A curl health-check polls /health every 2 s (up to 120 s) before returning, ensuring
+// the gateway starts only after qmd is accepting requests.
+func generateQmdStartStep(qmdConfig *QmdToolConfig) string {
+ version := string(constants.DefaultQmdVersion)
+ port := constants.DefaultQmdMCPPort
+
+ var sb strings.Builder
+ sb.WriteString(" - name: Start QMD MCP Server\n")
+ sb.WriteString(" id: qmd-mcp-start\n")
+ sb.WriteString(" env:\n")
+ sb.WriteString(" NO_COLOR: '1'\n")
+ if !qmdConfig.GPU {
+ sb.WriteString(" QMD_NODE_LLAMA_CPP_GPU: 'false'\n")
+ }
+ sb.WriteString(" run: |\n")
+ sb.WriteString(" # Start qmd MCP server in HTTP mode (avoids stdout/JSON-RPC conflicts).\n")
+ sb.WriteString(" # node-llama-cpp writes dot-progress directly to process.stdout which\n")
+ sb.WriteString(" # would corrupt the stdio JSON-RPC stream; HTTP transport avoids this.\n")
+ sb.WriteString(" docker run -d --rm \\\n")
+ sb.WriteString(" --name qmd-mcp-server \\\n")
+ sb.WriteString(" --network host \\\n")
+ sb.WriteString(" -v /tmp/gh-aw:/tmp/gh-aw:ro \\\n")
+ sb.WriteString(" -v \"${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw\" \\\n")
+ sb.WriteString(" -e INDEX_PATH=/tmp/gh-aw/qmd-index/index.sqlite \\\n")
+ sb.WriteString(" -e \"HOME=${HOME}\" \\\n")
+ sb.WriteString(" -e \"NO_COLOR=${NO_COLOR}\" \\\n")
+ if !qmdConfig.GPU {
+ sb.WriteString(" -e \"NODE_LLAMA_CPP_GPU=${QMD_NODE_LLAMA_CPP_GPU}\" \\\n")
+ }
+ sb.WriteString(" node:24 \\\n")
+ sb.WriteString(" npx --yes --package @tobilu/qmd@" + version + " qmd mcp --http --port " + strconv.Itoa(port) + "\n")
+ sb.WriteString(" \n")
+ sb.WriteString(" # Wait up to 120 s for the server to accept requests\n")
+ sb.WriteString(" echo 'Waiting for QMD MCP server on port " + strconv.Itoa(port) + "...'\n")
+ sb.WriteString(" for i in $(seq 1 60); do\n")
+ sb.WriteString(" if curl -sf http://localhost:" + strconv.Itoa(port) + "/health > /dev/null 2>&1; then\n")
+ sb.WriteString(" echo 'QMD MCP server is ready'\n")
+ sb.WriteString(" break\n")
+ sb.WriteString(" fi\n")
+ sb.WriteString(" if [ \"$i\" -eq 60 ]; then\n")
+ sb.WriteString(" echo 'ERROR: QMD MCP server failed to start within 120 s' >&2\n")
+ sb.WriteString(" docker logs qmd-mcp-server 2>&1 || true\n")
+ sb.WriteString(" exit 1\n")
+ sb.WriteString(" fi\n")
+ sb.WriteString(" sleep 2\n")
+ sb.WriteString(" done\n")
+ sb.WriteString(" \n")
+ return sb.String()
+}
+
// generateQmdModelsCacheStep generates a step that caches the qmd embedding models directory
// (~/.cache/qmd/models/) using the actions/cache action (restore + post-save), keyed by OS
// and qmd version. This step should be emitted in the indexing job (before index building) to
From 16290ab7c8afc9d9dd81d14b27876a60d9ff0f13 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 07:26:16 +0000
Subject: [PATCH 41/49] fix: run qmd MCP server natively on VM instead of in
Docker container
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0ce24d39-ceb1-447b-86ee-fa8fcce7a34b
---
.github/workflows/dev.lock.yml | 31 +++++++-------
.github/workflows/smoke-codex.lock.yml | 31 +++++++-------
pkg/workflow/mcp_renderer_builtin.go | 6 +--
pkg/workflow/mcp_setup_generator.go | 7 ++-
pkg/workflow/qmd.go | 59 ++++++++++++++------------
5 files changed, 68 insertions(+), 66 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 2931a3c4c27..50bc03ed6ae 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -549,26 +549,25 @@ jobs:
bash ${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh
+ - name: Setup Node.js for qmd MCP server
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: "24"
- name: Start QMD MCP Server
id: qmd-mcp-start
env:
+ INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
NO_COLOR: '1'
- QMD_NODE_LLAMA_CPP_GPU: 'false'
+ NODE_LLAMA_CPP_GPU: 'false'
run: |
- # Start qmd MCP server in HTTP mode (avoids stdout/JSON-RPC conflicts).
- # node-llama-cpp writes dot-progress directly to process.stdout which
- # would corrupt the stdio JSON-RPC stream; HTTP transport avoids this.
- docker run -d --rm \
- --name qmd-mcp-server \
- --network host \
- -v /tmp/gh-aw:/tmp/gh-aw:ro \
- -v "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw" \
- -e INDEX_PATH=/tmp/gh-aw/qmd-index/index.sqlite \
- -e "HOME=${HOME}" \
- -e "NO_COLOR=${NO_COLOR}" \
- -e "NODE_LLAMA_CPP_GPU=${QMD_NODE_LLAMA_CPP_GPU}" \
- node:24 \
- npx --yes --package @tobilu/qmd@2.0.1 qmd mcp --http --port 8181
+ # Start qmd MCP server natively in HTTP mode.
+ # qmd must run on the host VM (not in Docker) because node-llama-cpp
+ # requires platform-native binaries that cannot run in a generic container.
+ # HTTP transport keeps MCP traffic on TCP, fully separate from stdout.
+ npx --yes --package @tobilu/qmd@2.0.1 qmd mcp --http --port 8181 \
+ >> /tmp/qmd-mcp.log 2>&1 &
+ # Save PID for logs; the GitHub Actions runner terminates all processes at job end.
+ echo $! > /tmp/qmd-mcp.pid
# Wait up to 120 s for the server to accept requests
echo 'Waiting for QMD MCP server on port 8181...'
@@ -579,7 +578,7 @@ jobs:
fi
if [ "$i" -eq 60 ]; then
echo 'ERROR: QMD MCP server failed to start within 120 s' >&2
- docker logs qmd-mcp-server 2>&1 || true
+ cat /tmp/qmd-mcp.log 2>&1 || true
exit 1
fi
sleep 2
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index a325d43f7fd..8c358f8eea2 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -826,26 +826,25 @@ jobs:
bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_scripts_server.sh
+ - name: Setup Node.js for qmd MCP server
+ uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
+ with:
+ node-version: "24"
- name: Start QMD MCP Server
id: qmd-mcp-start
env:
+ INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
NO_COLOR: '1'
- QMD_NODE_LLAMA_CPP_GPU: 'false'
+ NODE_LLAMA_CPP_GPU: 'false'
run: |
- # Start qmd MCP server in HTTP mode (avoids stdout/JSON-RPC conflicts).
- # node-llama-cpp writes dot-progress directly to process.stdout which
- # would corrupt the stdio JSON-RPC stream; HTTP transport avoids this.
- docker run -d --rm \
- --name qmd-mcp-server \
- --network host \
- -v /tmp/gh-aw:/tmp/gh-aw:ro \
- -v "${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw" \
- -e INDEX_PATH=/tmp/gh-aw/qmd-index/index.sqlite \
- -e "HOME=${HOME}" \
- -e "NO_COLOR=${NO_COLOR}" \
- -e "NODE_LLAMA_CPP_GPU=${QMD_NODE_LLAMA_CPP_GPU}" \
- node:24 \
- npx --yes --package @tobilu/qmd@2.0.1 qmd mcp --http --port 8181
+ # Start qmd MCP server natively in HTTP mode.
+ # qmd must run on the host VM (not in Docker) because node-llama-cpp
+ # requires platform-native binaries that cannot run in a generic container.
+ # HTTP transport keeps MCP traffic on TCP, fully separate from stdout.
+ npx --yes --package @tobilu/qmd@2.0.1 qmd mcp --http --port 8181 \
+ >> /tmp/qmd-mcp.log 2>&1 &
+ # Save PID for logs; the GitHub Actions runner terminates all processes at job end.
+ echo $! > /tmp/qmd-mcp.pid
# Wait up to 120 s for the server to accept requests
echo 'Waiting for QMD MCP server on port 8181...'
@@ -856,7 +855,7 @@ jobs:
fi
if [ "$i" -eq 60 ]; then
echo 'ERROR: QMD MCP server failed to start within 120 s' >&2
- docker logs qmd-mcp-server 2>&1 || true
+ cat /tmp/qmd-mcp.log 2>&1 || true
exit 1
fi
sleep 2
diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go
index bc1a4be6632..c126bbddf94 100644
--- a/pkg/workflow/mcp_renderer_builtin.go
+++ b/pkg/workflow/mcp_renderer_builtin.go
@@ -74,7 +74,7 @@ func (r *MCPConfigRendererUnified) renderPlaywrightTOML(yaml *strings.Builder, p
}
// RenderQmdMCP generates the qmd documentation search MCP server configuration.
-// qmd is started as a separate Docker container (node:24) with HTTP transport by the
+// qmd runs natively on the host VM with HTTP transport, started by the
// "Start QMD MCP Server" step before the gateway, so the gateway connects via HTTP.
// Using HTTP transport avoids node-llama-cpp's direct process.stdout writes (dot-progress
// during model loading) from corrupting the stdio JSON-RPC stream.
@@ -96,7 +96,7 @@ func (r *MCPConfigRendererUnified) RenderQmdMCP(yaml *strings.Builder, qmdTool a
}
// resolveQmdHost returns the hostname the gateway should use to reach the qmd HTTP server.
-// qmd starts with --network host, so port DefaultQmdMCPPort is bound on the host network.
+// qmd runs natively on the host VM, so port DefaultQmdMCPPort is bound on the host network.
// When the agent sandbox is enabled (default), the gateway runs inside a Docker container
// with its own network namespace and must reach the host via host.docker.internal.
// When the agent sandbox is disabled (agent.disabled: true), the gateway also runs on
@@ -116,7 +116,7 @@ func qmdMCPURL(workflowData *WorkflowData) string {
}
// renderQmdTOML generates qmd MCP configuration in TOML format using HTTP transport.
-// qmd is started as a separate Docker container before the gateway (see generateQmdStartStep),
+// qmd is started natively before the gateway (see generateQmdStartStep),
// and the gateway connects to the qmd HTTP MCP server at DefaultQmdMCPPort/mcp.
func (r *MCPConfigRendererUnified) renderQmdTOML(yaml *strings.Builder, workflowData *WorkflowData) {
mcpRendererBuiltinLog.Print("Rendering qmd MCP in TOML format (HTTP transport)")
diff --git a/pkg/workflow/mcp_setup_generator.go b/pkg/workflow/mcp_setup_generator.go
index be88d53b680..aeed760aa50 100644
--- a/pkg/workflow/mcp_setup_generator.go
+++ b/pkg/workflow/mcp_setup_generator.go
@@ -467,10 +467,9 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any,
yaml.WriteString(" \n")
}
- // Start the qmd MCP HTTP server if qmd is configured.
- // qmd runs in HTTP mode (docker run --network host ... qmd mcp --http) before the gateway
- // so the gateway connects to it via HTTP. This avoids node-llama-cpp's process.stdout
- // writes (dot-progress during model loading) from corrupting the stdio JSON-RPC stream.
+ // Start the qmd MCP HTTP server natively if qmd is configured.
+ // qmd must run on the host VM (not in Docker) because node-llama-cpp compiles
+ // platform-native binaries. HTTP mode keeps MCP traffic on TCP, separate from stdout.
if workflowData != nil && workflowData.QmdConfig != nil {
yaml.WriteString(generateQmdStartStep(workflowData.QmdConfig))
}
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index cea85e57090..2fe06b829b1 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -92,8 +92,11 @@ func qmdHasSources(qmdConfig *QmdToolConfig) bool {
return len(qmdConfig.Checkouts) > 0 || len(qmdConfig.Searches) > 0
}
-// generateQmdStartStep generates a GitHub Actions step that starts the qmd MCP server
-// in HTTP mode as a background Docker container before the MCP gateway.
+// generateQmdStartStep generates two GitHub Actions steps that set up and start the qmd MCP
+// server in HTTP mode natively on the runner VM, before the MCP gateway.
+//
+// qmd must run natively (not in Docker) because node-llama-cpp compiles platform-specific
+// binaries that must match the runner's CPU/OS and cannot run inside a generic Docker image.
//
// Using HTTP transport (qmd mcp --http) avoids node-llama-cpp's direct process.stdout writes
// (e.g. dot-progress characters during model loading) from being mixed into the stdio
@@ -101,52 +104,54 @@ func qmdHasSources(qmdConfig *QmdToolConfig) bool {
// parse errors in the gateway. With HTTP transport the MCP protocol travels over TCP, so
// qmd's stdout/stderr are completely independent of the protocol channel.
//
-// The container mounts:
-// - /tmp/gh-aw (read-only): qmd index SQLite file restored by the cache-restore step
-// - ${HOME}/.cache/qmd (read-write): embedding model GGUF files and node-llama-cpp state
+// The two steps are:
+// 1. Setup Node.js – ensures node v24 is available before running npx.
+// 2. Start QMD MCP Server – installs @tobilu/qmd via npx, starts the HTTP server as a
+// background process, and polls /health (up to 120 s) before continuing.
//
-// A curl health-check polls /health every 2 s (up to 120 s) before returning, ensuring
-// the gateway starts only after qmd is accepting requests.
+// The gateway then connects to http://localhost:{port}/mcp.
func generateQmdStartStep(qmdConfig *QmdToolConfig) string {
version := string(constants.DefaultQmdVersion)
port := constants.DefaultQmdMCPPort
+ portStr := strconv.Itoa(port)
var sb strings.Builder
+
+ // Step 1: Setup Node.js (node:24 required by @tobilu/qmd)
+ sb.WriteString(" - name: Setup Node.js for qmd MCP server\n")
+ fmt.Fprintf(&sb, " uses: %s\n", GetActionPin("actions/setup-node"))
+ sb.WriteString(" with:\n")
+ fmt.Fprintf(&sb, " node-version: \"%s\"\n", string(constants.DefaultNodeVersion))
+
+ // Step 2: Start qmd natively
sb.WriteString(" - name: Start QMD MCP Server\n")
sb.WriteString(" id: qmd-mcp-start\n")
sb.WriteString(" env:\n")
+ sb.WriteString(" INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite\n")
sb.WriteString(" NO_COLOR: '1'\n")
if !qmdConfig.GPU {
- sb.WriteString(" QMD_NODE_LLAMA_CPP_GPU: 'false'\n")
+ sb.WriteString(" NODE_LLAMA_CPP_GPU: 'false'\n")
}
sb.WriteString(" run: |\n")
- sb.WriteString(" # Start qmd MCP server in HTTP mode (avoids stdout/JSON-RPC conflicts).\n")
- sb.WriteString(" # node-llama-cpp writes dot-progress directly to process.stdout which\n")
- sb.WriteString(" # would corrupt the stdio JSON-RPC stream; HTTP transport avoids this.\n")
- sb.WriteString(" docker run -d --rm \\\n")
- sb.WriteString(" --name qmd-mcp-server \\\n")
- sb.WriteString(" --network host \\\n")
- sb.WriteString(" -v /tmp/gh-aw:/tmp/gh-aw:ro \\\n")
- sb.WriteString(" -v \"${HOME}/.cache/qmd:${HOME}/.cache/qmd:rw\" \\\n")
- sb.WriteString(" -e INDEX_PATH=/tmp/gh-aw/qmd-index/index.sqlite \\\n")
- sb.WriteString(" -e \"HOME=${HOME}\" \\\n")
- sb.WriteString(" -e \"NO_COLOR=${NO_COLOR}\" \\\n")
- if !qmdConfig.GPU {
- sb.WriteString(" -e \"NODE_LLAMA_CPP_GPU=${QMD_NODE_LLAMA_CPP_GPU}\" \\\n")
- }
- sb.WriteString(" node:24 \\\n")
- sb.WriteString(" npx --yes --package @tobilu/qmd@" + version + " qmd mcp --http --port " + strconv.Itoa(port) + "\n")
+ sb.WriteString(" # Start qmd MCP server natively in HTTP mode.\n")
+ sb.WriteString(" # qmd must run on the host VM (not in Docker) because node-llama-cpp\n")
+ sb.WriteString(" # requires platform-native binaries that cannot run in a generic container.\n")
+ sb.WriteString(" # HTTP transport keeps MCP traffic on TCP, fully separate from stdout.\n")
+ sb.WriteString(" npx --yes --package @tobilu/qmd@" + version + " qmd mcp --http --port " + portStr + " \\\n")
+ sb.WriteString(" >> /tmp/qmd-mcp.log 2>&1 &\n")
+ sb.WriteString(" # Save PID for logs; the GitHub Actions runner terminates all processes at job end.\n")
+ sb.WriteString(" echo $! > /tmp/qmd-mcp.pid\n")
sb.WriteString(" \n")
sb.WriteString(" # Wait up to 120 s for the server to accept requests\n")
- sb.WriteString(" echo 'Waiting for QMD MCP server on port " + strconv.Itoa(port) + "...'\n")
+ sb.WriteString(" echo 'Waiting for QMD MCP server on port " + portStr + "...'\n")
sb.WriteString(" for i in $(seq 1 60); do\n")
- sb.WriteString(" if curl -sf http://localhost:" + strconv.Itoa(port) + "/health > /dev/null 2>&1; then\n")
+ sb.WriteString(" if curl -sf http://localhost:" + portStr + "/health > /dev/null 2>&1; then\n")
sb.WriteString(" echo 'QMD MCP server is ready'\n")
sb.WriteString(" break\n")
sb.WriteString(" fi\n")
sb.WriteString(" if [ \"$i\" -eq 60 ]; then\n")
sb.WriteString(" echo 'ERROR: QMD MCP server failed to start within 120 s' >&2\n")
- sb.WriteString(" docker logs qmd-mcp-server 2>&1 || true\n")
+ sb.WriteString(" cat /tmp/qmd-mcp.log 2>&1 || true\n")
sb.WriteString(" exit 1\n")
sb.WriteString(" fi\n")
sb.WriteString(" sleep 2\n")
From 203269bd04b6132c47751e34b033e5caec0bebc7 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 19:19:08 +0000
Subject: [PATCH 42/49] placeholder for planning
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/78657957-9d39-4cf3-8b27-2f585b36aeb4
---
.github/workflows/dev.lock.yml | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index cd3a5ba4001..3faf667cc7e 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -40,8 +40,9 @@ name: "Dev"
workflow_dispatch:
inputs:
item_number:
+ default: ""
description: The number of the issue, pull request, or discussion
- required: true
+ required: false
type: string
permissions: {}
From 9f0119bba4cc70dcf2a750958147da1f6cc573fa Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 19:29:06 +0000
Subject: [PATCH 43/49] feat(qmd): use aw-gpu-runner-T4 as default indexing job
runner
Change the default runner for the qmd indexing job from ubuntu-latest
to aw-gpu-runner-T4, enabling GPU acceleration for node-llama-cpp
during the embedding phase. Users can still override with runs-on:.
Add DefaultQmdIndexingRunnerImage constant in pkg/constants/constants.go.
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/78657957-9d39-4cf3-8b27-2f585b36aeb4
---
.github/workflows/dev.lock.yml | 2 +-
.github/workflows/smoke-codex.lock.yml | 2 +-
pkg/constants/constants.go | 4 ++++
pkg/workflow/qmd.go | 4 ++--
4 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 3faf667cc7e..816716f6d94 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -1095,7 +1095,7 @@ jobs:
indexing:
needs: activation
- runs-on: ubuntu-latest
+ runs-on: aw-gpu-runner-T4
permissions:
contents: read
timeout-minutes: 60
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 866e83a5d75..a539d11bf5a 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -1524,7 +1524,7 @@ jobs:
indexing:
needs: activation
- runs-on: ubuntu-latest
+ runs-on: aw-gpu-runner-T4
permissions:
contents: read
timeout-minutes: 60
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index 9a3e8f9e1f7..bcde5cec66e 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -433,6 +433,10 @@ const DefaultPlaywrightMCPVersion Version = "0.0.68"
// DefaultQmdVersion is the default version of the @tobilu/qmd npm package
const DefaultQmdVersion Version = "2.0.1"
+// DefaultQmdIndexingRunnerImage is the default runner image for the qmd indexing job.
+// Uses the GPU-enabled T4 runner image so node-llama-cpp can leverage the GPU during embedding.
+const DefaultQmdIndexingRunnerImage = "aw-gpu-runner-T4"
+
// DefaultPlaywrightBrowserVersion is the default version of the Playwright browser Docker image
const DefaultPlaywrightBrowserVersion Version = "v1.58.2"
diff --git a/pkg/workflow/qmd.go b/pkg/workflow/qmd.go
index 2fe06b829b1..6c2df28013a 100644
--- a/pkg/workflow/qmd.go
+++ b/pkg/workflow/qmd.go
@@ -598,8 +598,8 @@ func (c *Compiler) buildQmdIndexingJob(data *WorkflowData) (*Job, error) {
})
// Determine the runner for the indexing job.
- // Default to ubuntu-latest; user can override via qmd.runs-on.
- indexingRunsOn := "runs-on: ubuntu-latest"
+ // Default to aw-gpu-runner-T4 for GPU-accelerated embedding; user can override via qmd.runs-on.
+ indexingRunsOn := "runs-on: " + constants.DefaultQmdIndexingRunnerImage
if data.QmdConfig.RunsOn != "" {
indexingRunsOn = "runs-on: " + data.QmdConfig.RunsOn
}
From c708c4f10da6aaeb6752742b0c39b5c5b0ab8588 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 20:17:45 +0000
Subject: [PATCH 44/49] revert default qmd runner to ubuntu-latest, set
aw-gpu-runner-T4 in dev.md
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/4b64a23b-c604-4c19-b141-d8b92660beba
---
.github/workflows/dev.lock.yml | 2 +-
.github/workflows/dev.md | 1 +
.github/workflows/smoke-codex.lock.yml | 2 +-
pkg/constants/constants.go | 4 ++--
4 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 816716f6d94..281fd2a4fb9 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -22,7 +22,7 @@
#
# Daily status report for gh-aw project
#
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"992b8a4df813dfa50732c5bbc1759c89ffb2eb7b5e2c569f214c17011a56970c","agent_id":"copilot"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"21d621c104cb8cd6dc46e5c3d530d0bc829d4b18e456f220fc72512c2c933219","agent_id":"copilot"}
name: "Dev"
"on":
diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md
index 02811962e99..91b910dd440 100644
--- a/.github/workflows/dev.md
+++ b/.github/workflows/dev.md
@@ -17,6 +17,7 @@ permissions:
tools:
qmd:
+ runs-on: aw-gpu-runner-T4
checkouts:
- name: docs
paths:
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index a539d11bf5a..866e83a5d75 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -1524,7 +1524,7 @@ jobs:
indexing:
needs: activation
- runs-on: aw-gpu-runner-T4
+ runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 60
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
index bcde5cec66e..7a70fc45d53 100644
--- a/pkg/constants/constants.go
+++ b/pkg/constants/constants.go
@@ -434,8 +434,8 @@ const DefaultPlaywrightMCPVersion Version = "0.0.68"
const DefaultQmdVersion Version = "2.0.1"
// DefaultQmdIndexingRunnerImage is the default runner image for the qmd indexing job.
-// Uses the GPU-enabled T4 runner image so node-llama-cpp can leverage the GPU during embedding.
-const DefaultQmdIndexingRunnerImage = "aw-gpu-runner-T4"
+// Users can override this with the runs-on: field in the qmd config.
+const DefaultQmdIndexingRunnerImage = "ubuntu-latest"
// DefaultPlaywrightBrowserVersion is the default version of the Playwright browser Docker image
const DefaultPlaywrightBrowserVersion Version = "v1.58.2"
From ca501cba580bb1f5cdb2ce88c10e01745e908820 Mon Sep 17 00:00:00 2001
From: Peli de Halleux
Date: Mon, 23 Mar 2026 20:26:57 +0000
Subject: [PATCH 45/49] feat: enable GPU support for QMD in dev workflow
---
.github/workflows/dev.lock.yml | 4 +---
.github/workflows/dev.md | 1 +
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 281fd2a4fb9..f6b44cf904b 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -22,7 +22,7 @@
#
# Daily status report for gh-aw project
#
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"21d621c104cb8cd6dc46e5c3d530d0bc829d4b18e456f220fc72512c2c933219","agent_id":"copilot"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"c8101abd859c9c5f5184918f1f1c3e4318f6ea629be65c3426b1136b97c65904","agent_id":"copilot"}
name: "Dev"
"on":
@@ -559,7 +559,6 @@ jobs:
env:
INDEX_PATH: /tmp/gh-aw/qmd-index/index.sqlite
NO_COLOR: '1'
- NODE_LLAMA_CPP_GPU: 'false'
run: |
# Start qmd MCP server natively in HTTP mode.
# qmd must run on the host VM (not in Docker) because node-llama-cpp
@@ -1148,7 +1147,6 @@ jobs:
env:
QMD_CONFIG_JSON: |
{"dbPath":"/tmp/gh-aw/qmd-index","checkouts":[{"name":"docs","path":"${GITHUB_WORKSPACE}","patterns":["docs/src/**/*.md","docs/src/**/*.mdx"],"context":"gh-aw project documentation"}],"searches":[{"name":"issues","type":"issues","max":500,"tokenEnvVar":"QMD_SEARCH_TOKEN_0"}]}
- NODE_LLAMA_CPP_GPU: "false"
QMD_SEARCH_TOKEN_0: ${{ secrets.GITHUB_TOKEN }}
with:
github-token: ${{ github.token }}
diff --git a/.github/workflows/dev.md b/.github/workflows/dev.md
index 91b910dd440..bb7908f3bc1 100644
--- a/.github/workflows/dev.md
+++ b/.github/workflows/dev.md
@@ -18,6 +18,7 @@ permissions:
tools:
qmd:
runs-on: aw-gpu-runner-T4
+ gpu: true
checkouts:
- name: docs
paths:
From 9bd64ccf245b9ec20ea19f1365c2cf822418fbfb Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 22:43:40 +0000
Subject: [PATCH 46/49] chore: merge main and recompile lock files
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b8c38a29-aabd-4f05-bdf2-e26dbc1e9529
---
.github/workflows/dev.lock.yml | 15 +++++++++++++++
.github/workflows/smoke-codex.lock.yml | 10 ++++++++++
2 files changed, 25 insertions(+)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 584277ac6bd..3c0dc26e57c 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -39,6 +39,11 @@ name: "Dev"
- cron: "0 9 * * *"
workflow_dispatch:
inputs:
+ aw_context:
+ default: ""
+ description: (Internal) JSON context injected by the calling agentic workflow. Not intended for direct user input.
+ required: false
+ type: string
item_number:
default: ""
description: The number of the issue, pull request, or discussion
@@ -376,6 +381,16 @@ jobs:
GH_HOST: github.com
- name: Install AWF binary
run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.0
+ - name: Restore qmd index from cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
+ - name: Restore qmd models cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/qmd/models/
+ key: qmd-models-2.0.1-${{ runner.os }}
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
diff --git a/.github/workflows/smoke-codex.lock.yml b/.github/workflows/smoke-codex.lock.yml
index 44294064454..ec6ba22da08 100644
--- a/.github/workflows/smoke-codex.lock.yml
+++ b/.github/workflows/smoke-codex.lock.yml
@@ -429,6 +429,16 @@ jobs:
run: npm install -g @openai/codex@latest
- name: Install AWF binary
run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.0
+ - name: Restore qmd index from cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ key: gh-aw-qmd-2.0.1-${{ github.run_id }}
+ path: /tmp/gh-aw/qmd-index/
+ - name: Restore qmd models cache
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/qmd/models/
+ key: qmd-models-2.0.1-${{ runner.os }}
- name: Determine automatic lockdown mode for GitHub MCP Server
id: determine-automatic-lockdown
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
From a009827acc40256827c2f29eb00e2c5202a8cea6 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 22:57:19 +0000
Subject: [PATCH 47/49] fix: resolve TypeScript undefined errors in
qmd_index.cjs
Use null-coalescing/short-circuit checks for optional fields (checkouts,
searches, min, max) so tsc --noEmit passes without errors.
- Extract config.checkouts ?? [] and config.searches ?? [] before
iterating in writeSummary
- Use && guards for search.max and search.min comparisons
- Use search.min ?? 0 for minCount variable
All 20 qmd_index tests still pass.
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d0d5208c-2141-4f99-b526-1be3cfed1b3c
---
actions/setup/js/qmd_index.cjs | 30 +++++++++++++++++-------------
1 file changed, 17 insertions(+), 13 deletions(-)
diff --git a/actions/setup/js/qmd_index.cjs b/actions/setup/js/qmd_index.cjs
index 02453c82097..4d076aeec71 100644
--- a/actions/setup/js/qmd_index.cjs
+++ b/actions/setup/js/qmd_index.cjs
@@ -44,11 +44,12 @@ async function writeSummary(config, updateResult, embedResult) {
try {
let md = "## qmd documentation index\n\n";
- if ((config.checkouts || []).length > 0) {
+ const checkouts = config.checkouts ?? [];
+ if (checkouts.length > 0) {
md += "### Collections\n\n";
md += "| Name | Patterns | Context |\n";
md += "| --- | --- | --- |\n";
- for (const col of config.checkouts) {
+ for (const col of checkouts) {
const patterns = (col.patterns || ["**/*.md"]).join(", ");
const ctx = col.context || "-";
md += `| ${col.name} | ${patterns} | ${ctx} |\n`;
@@ -56,16 +57,17 @@ async function writeSummary(config, updateResult, embedResult) {
md += "\n";
}
- if ((config.searches || []).length > 0) {
+ const searches = config.searches ?? [];
+ if (searches.length > 0) {
md += "### Searches\n\n";
md += "| Name | Type | Query / Repo | Min | Max |\n";
md += "| --- | --- | --- | --- | --- |\n";
- for (const s of config.searches) {
+ for (const s of searches) {
const name = s.name || "-";
const type = s.type || "code";
const ref = (s.query || s.repo || "-").replace(/\|/g, "\\|");
- const min = s.min > 0 ? String(s.min) : "-";
- const max = String(s.max > 0 ? s.max : type === "issues" ? 500 : 30);
+ const min = s.min && s.min > 0 ? String(s.min) : "-";
+ const max = String(s.max && s.max > 0 ? s.max : type === "issues" ? 500 : 30);
md += `| ${name} | ${type} | ${ref} | ${min} | ${max} |\n`;
}
md += "\n";
@@ -141,8 +143,9 @@ async function main() {
}
// ── Process search entries ───────────────────────────────────────────────
- for (let i = 0; i < (config.searches || []).length; i++) {
- const search = config.searches[i];
+ const searchEntries = config.searches ?? [];
+ for (let i = 0; i < searchEntries.length; i++) {
+ const search = searchEntries[i];
const collectionName = search.name || `search-${i}`;
const searchDir = `/tmp/gh-aw/qmd-search-${i}`;
fs.mkdirSync(searchDir, { recursive: true });
@@ -157,7 +160,7 @@ async function main() {
return;
}
const [owner, repo] = slugParts;
- const maxCount = search.max > 0 ? search.max : 500;
+ const maxCount = search.max && search.max > 0 ? search.max : 500;
core.info(`Fetching issues from ${repoSlug} (max: ${maxCount})…`);
@@ -177,7 +180,7 @@ async function main() {
core.info(`Saved ${slice.length} issues to ${searchDir}`);
} else {
// Code search: download matching files via GitHub REST API.
- const maxCount = search.max > 0 ? search.max : 30;
+ const maxCount = search.max && search.max > 0 ? search.max : 30;
core.info(`Searching GitHub code: "${search.query}" (max: ${maxCount})…`);
const response = await client.rest.search.code({
@@ -211,10 +214,11 @@ async function main() {
}
// Enforce minimum result count.
- if (search.min > 0) {
+ const minCount = search.min ?? 0;
+ if (minCount > 0) {
const fileCount = fs.readdirSync(searchDir).length;
- if (fileCount < search.min) {
- core.setFailed(`qmd search "${collectionName}" returned ${fileCount} results, minimum is ${search.min}`);
+ if (fileCount < minCount) {
+ core.setFailed(`qmd search "${collectionName}" returned ${fileCount} results, minimum is ${minCount}`);
return;
}
}
From 4dec20d4f7f1a8f6ec862dbcfa1e87e15619999b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 23:09:19 +0000
Subject: [PATCH 48/49] plan: fix invalid frontmatter anchor links in qmd docs
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e8e29554-d8d1-45de-9bb0-617854e9fd90
---
.github/workflows/dev.lock.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml
index 6113c604600..e9162266bb7 100644
--- a/.github/workflows/dev.lock.yml
+++ b/.github/workflows/dev.lock.yml
@@ -1086,7 +1086,7 @@ jobs:
id: conclusion
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
- GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }}
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }}
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
From 9b17576a8e95395c5f76a88858c9ecfb4dd56206 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Mar 2026 23:09:55 +0000
Subject: [PATCH 49/49] fix: correct invalid frontmatter anchor links in qmd
docs
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e8e29554-d8d1-45de-9bb0-617854e9fd90
---
docs/src/content/docs/reference/qmd.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/src/content/docs/reference/qmd.md b/docs/src/content/docs/reference/qmd.md
index d39910702d3..f334b7657a6 100644
--- a/docs/src/content/docs/reference/qmd.md
+++ b/docs/src/content/docs/reference/qmd.md
@@ -60,7 +60,7 @@ tools:
path: ./other-repo # optional; defaults to /tmp/gh-aw/qmd-checkout-
```
-Each `checkout:` entry accepts the same options as the top-level [`checkout:`](/gh-aw/reference/frontmatter/#checkout) field: `repository`, `ref`, `path`, `token`, `fetch-depth`, `sparse-checkout`, `submodules`, and `lfs`.
+Each `checkout:` entry accepts the same options as the top-level [`checkout:`](/gh-aw/reference/frontmatter/#repository-checkout-checkout) field: `repository`, `ref`, `path`, `token`, `fetch-depth`, `sparse-checkout`, `submodules`, and `lfs`.
The optional `context:` field provides additional hints to the agent about the collection's content (e.g. product area, audience, or version).
@@ -156,7 +156,7 @@ tools:
| `name` | `string` | No | Collection identifier (defaults to `"docs-"`). |
| `paths` | `string[]` | No | Glob patterns for files to include (defaults to `**/*.md`). |
| `context` | `string` | No | Optional context hint for the agent about this collection's content (e.g. `"GitHub Actions documentation"`). |
-| `checkout` | `CheckoutConfig` | No | Repository checkout options — same syntax as the top-level [`checkout:`](/gh-aw/reference/frontmatter/#checkout) field. Defaults to the current repository. |
+| `checkout` | `CheckoutConfig` | No | Repository checkout options — same syntax as the top-level [`checkout:`](/gh-aw/reference/frontmatter/#repository-checkout-checkout) field. Defaults to the current repository. |
### `QmdSearchEntry` fields
@@ -191,6 +191,6 @@ The tool returns file paths ranked by relevance. Use standard file reading to fe
## Related Documentation
- [Tools](/gh-aw/reference/tools/) - Overview of all built-in tools
-- [Frontmatter](/gh-aw/reference/frontmatter/#checkout) - Top-level checkout configuration
+- [Frontmatter](/gh-aw/reference/frontmatter/#repository-checkout-checkout) - Top-level checkout configuration
- [Permissions](/gh-aw/reference/permissions/) - GitHub Actions permission configuration
- [Dependabot](/gh-aw/reference/dependabot/) - Automatic dependency updates (tracks `@tobilu/qmd` version)