From 4b601d184e74076ed4f0ee1bd44773b4d3bda549 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:32:50 +0000 Subject: [PATCH] Refactor ambient folder artifacts Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/shared/squad.md | 9 ++- .github/workflows/squad-game-planner.lock.yml | 50 ++----------- ...nt-folders-activation-artifact-bundling.md | 8 +-- .../src/content/docs/reference/frontmatter.md | 2 +- docs/src/content/docs/reference/imports.md | 2 +- pkg/constants/job_constants.go | 7 -- pkg/parser/content_extractor.go | 30 ++++++++ pkg/parser/import_field_extractor.go | 4 +- pkg/parser/import_field_extractor_test.go | 14 ++-- pkg/parser/import_processor.go | 2 +- pkg/parser/schema_validation.go | 2 - pkg/parser/schemas/main_workflow_schema.json | 48 ++++++------- pkg/workflow/ambient_folders.go | 70 ++----------------- pkg/workflow/compiler_activation_outputs.go | 5 +- pkg/workflow/compiler_artifacts_test.go | 27 +++---- pkg/workflow/compiler_custom_jobs.go | 3 +- pkg/workflow/compiler_custom_jobs_test.go | 7 +- pkg/workflow/compiler_yaml_ai_execution.go | 1 - pkg/workflow/compiler_yaml_runtime_setup.go | 14 ++-- pkg/workflow/event_validation.go | 1 - 20 files changed, 104 insertions(+), 202 deletions(-) diff --git a/.github/workflows/shared/squad.md b/.github/workflows/shared/squad.md index 2e30409ca93..74a22a4fed9 100644 --- a/.github/workflows/shared/squad.md +++ b/.github/workflows/shared/squad.md @@ -26,10 +26,9 @@ # the activation job. engine: id: copilot -on: - ambient-folders: - - .squad - - .github/agents +ambient-folders: + - .squad + - .github/agents jobs: activation: @@ -62,7 +61,7 @@ install/init lifecycle out of the agent job: npm release, optionally mints a GitHub App installation token (or uses a supplied PAT) so `squad init` can see other organizations or private repositories, and runs `squad init --preset default` (idempotent). -2. **`on.ambient-folders`** — bundles the resulting `.squad/` team state and +2. **`ambient-folders`** — bundles the resulting `.squad/` team state and `.github/agents/` files into the standard activation artifact alongside the rest of the prompt/skills/sub-agent packaging, then restores them into the agent checkout. The Squad CLI itself is never installed in the agent job; only the diff --git a/.github/workflows/squad-game-planner.lock.yml b/.github/workflows/squad-game-planner.lock.yml index fbaa6497dcb..26bd2216979 100644 --- a/.github/workflows/squad-game-planner.lock.yml +++ b/.github/workflows/squad-game-planner.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e5f0d2bcfa01017868488e1feddee076cb22692a55346b257cbb16b754688989","body_hash":"a008af4fd8783aff90c76fb260ca35ee6424023b59c00b7d6f2d0a9f0f95fc0a","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.77"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"58948c9bfd02f59e5e15617e524e9df3753ed5d3510f3a6c8ae61ccd6229131d","body_hash":"ce252d7e528c8553800c425ac3ac91f0d9cae2fdf0cc62c670b528138db16dd8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.77"}} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN","SQUAD_GITHUB_APP_PRIVATE_KEY","SQUAD_GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/create-github-app-token","sha":"bcd2ba49218906704ab6c1aa796996da409d3eb1","version":"v3.2.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43","digest":"sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.43@sha256:04e2d1987a565000a8f114b89d806ae7a3864dd4f944be65275b28c93d8690e6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43","digest":"sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.43@sha256:d85f57975af5ea23af4996e41ed73fbc8f5b4a47402472bfe82e508f352cb0c1"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.43","digest":"sha256:65c45ea2967984d0024f3df61bc71335658a77ede96c8d9665da7a5f33a795ab","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.43@sha256:65c45ea2967984d0024f3df61bc71335658a77ede96c8d9665da7a5f33a795ab"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43","digest":"sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.43@sha256:26be5e0b8c8f4c41c8a59126b29bb5d80b07253597472ded2a16bdd75abcbf9d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.7","digest":"sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.7@sha256:7545220a9aca134b71e51193ee0eaf4c50756ebf8fbd25a63ae7556e62815c00"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.8.0","digest":"sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520","pinned_image":"ghcr.io/github/github-mcp-server:v1.8.0@sha256:d5a18c04b92714c309eb46a2305087e91a4dbd80420f6e462656699f95093520"}]} # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -373,21 +373,6 @@ jobs: env: GH_TOKEN: ${{ steps.squad-app-token.outputs.token || secrets.SQUAD_GITHUB_TOKEN || github.token }} SQUAD_CLI_VERSION: ${{ vars.SQUAD_CLI_VERSION }} - - name: Stage ambient folders for activation artifact - env: - GH_AW_AMBIENT_FOLDERS: ".squad .github/agents" - # poutine:ignore untrusted_checkout_exec - run: | - mkdir -p /tmp/gh-aw/ambient-folders - for folder in $GH_AW_AMBIENT_FOLDERS; do - src="$GITHUB_WORKSPACE/$folder" - dst="/tmp/gh-aw/ambient-folders/$folder" - if [ -e "$src" ]; then - mkdir -p "$(dirname "$dst")" - rm -rf "$dst" - cp -a "$src" "$dst" - fi - done - name: Upload activation artifact if: success() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -402,7 +387,8 @@ jobs: /tmp/gh-aw/aw-prompts/prompt-import-tree.json /tmp/gh-aw/github_rate_limits.jsonl /tmp/gh-aw/base - /tmp/gh-aw/ambient-folders + .squad + .github/agents /tmp/gh-aw/.github/agents /tmp/gh-aw/.github/skills if-no-files-found: ignore @@ -497,21 +483,7 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: activation - path: /tmp/gh-aw - - name: Restore ambient folders from activation artifact - env: - GH_AW_AMBIENT_FOLDERS: ".squad .github/agents" - # poutine:ignore untrusted_checkout_exec - run: | - for folder in $GH_AW_AMBIENT_FOLDERS; do - src="/tmp/gh-aw/ambient-folders/$folder" - dst="$GITHUB_WORKSPACE/$folder" - if [ -e "$src" ]; then - mkdir -p "$(dirname "$dst")" - rm -rf "$dst" - cp -a "$src" "$dst" - fi - done + path: / - name: Configure Git credentials env: GITHUB_REPOSITORY: ${{ github.repository }} @@ -555,20 +527,6 @@ jobs: GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .gemini .github .opencode .pi" GH_AW_AGENT_FILES: "AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh" - - name: Restore ambient folders from activation artifact - env: - GH_AW_AMBIENT_FOLDERS: ".squad .github/agents" - # poutine:ignore untrusted_checkout_exec - run: | - for folder in $GH_AW_AMBIENT_FOLDERS; do - src="/tmp/gh-aw/ambient-folders/$folder" - dst="$GITHUB_WORKSPACE/$folder" - if [ -e "$src" ]; then - mkdir -p "$(dirname "$dst")" - rm -rf "$dst" - cp -a "$src" "$dst" - fi - done - name: Restore inline sub-agents from activation artifact env: GH_AW_SUB_AGENT_DIR: ".github/agents" diff --git a/docs/adr/50108-ambient-folders-activation-artifact-bundling.md b/docs/adr/50108-ambient-folders-activation-artifact-bundling.md index 5ad4d72c4f5..eb69fc8a62a 100644 --- a/docs/adr/50108-ambient-folders-activation-artifact-bundling.md +++ b/docs/adr/50108-ambient-folders-activation-artifact-bundling.md @@ -12,7 +12,7 @@ Shared workflows that run activation steps (e.g., Squad CLI initialization) prod ### Decision -We will introduce a new `on.ambient-folders` frontmatter field in gh-aw workflow markdown files. Shared workflows declare the workspace-relative folder paths they produce; the compiler merges those declarations across all imports (deduplicating), adds the declared folders to the activation sparse-checkout, stages them into `/tmp/gh-aw/ambient-folders/` immediately before the activation artifact is uploaded, includes that staging directory in the artifact path list, and emits a restore step in the agent job after the last custom checkout (so multi-checkout workflows do not lose ambient content). Workflows whose `on:` block contains only import-safe keys (including `ambient-folders`) are classified as shared components rather than standalone workflows. +We will introduce a top-level `ambient-folders` frontmatter field in gh-aw workflow markdown files. Shared workflows declare the workspace-relative folder paths they produce; the compiler merges those declarations across all imports (deduplicating), adds the declared folders to the activation sparse-checkout, and adds them directly to the activation artifact path list. The artifact is extracted at its root in downstream jobs, preserving both the generated `/tmp/gh-aw` files and the declared workspace folders. Workflows with no trigger event remain shared components. ### Alternatives Considered @@ -31,8 +31,7 @@ Not chosen because: it couples the platform to specific tooling choices, does no ### Consequences #### Positive -- Shared workflows can declare their folder dependencies once in frontmatter; the compiler handles staging and restore automatically, removing the need for per-consumer download steps. -- The restore step is injected after the last custom checkout, which prevents multi-checkout workflows from clobbering ambient content. +- Shared workflows can declare their folder dependencies once in frontmatter; the compiler packages them with the standard activation artifact, removing the need for per-consumer download steps. - The merge strategy (union/deduplicated) means multiple shared workflows each declaring overlapping folders still produce a single coherent restore. - The field is validated by JSON Schema with path-traversal protections (no `..`, no absolute paths), keeping the attack surface small. @@ -41,8 +40,7 @@ Not chosen because: it couples the platform to specific tooling choices, does no - The shared-workflow classification logic now depends on an `on:` field value inspection (`IsImportSafeSharedWorkflowOn`), which increases coupling between the parser and compiler orchestration. #### Neutral -- Workflows with `on: ambient-folders: [...]` and no trigger event are now classified as shared components, consistent with the existing behaviour for other import-safe `on:` keys (`skip-if-match`, `github-token`, etc.). -- The staging script uses `cp -a` (archive copy) and silently skips missing source folders, so workflows that conditionally produce folders do not fail the activation job. +- Workflows with `ambient-folders: [...]` and no trigger event are classified as shared components. --- diff --git a/docs/src/content/docs/reference/frontmatter.md b/docs/src/content/docs/reference/frontmatter.md index d468e42497d..baaa9953946 100644 --- a/docs/src/content/docs/reference/frontmatter.md +++ b/docs/src/content/docs/reference/frontmatter.md @@ -82,7 +82,7 @@ The `on:` section uses standard GitHub Actions syntax to define workflow trigger - `restore-memory:` - Opt in to restoring memory stores before `on.steps` in pre-activation (default: `false`) - `permissions:` - Grant additional GitHub token scopes to the pre-activation job (for use with `on.steps:` API calls) - `needs:` - Add custom job dependencies that both `pre_activation` and `activation` must wait for -- `ambient-folders:` - Workspace-relative folders to bundle in the activation artifact and restore before the agent runs +- `ambient-folders:` - Workspace-relative folders to include in the activation artifact - `github-token:` - Custom token for activation job reactions, status comments, and skip-if search queries - `github-app:` - GitHub App for minting a short-lived token used by the activation job and all skip-if search steps diff --git a/docs/src/content/docs/reference/imports.md b/docs/src/content/docs/reference/imports.md index 8c1025b7aa7..f2b5d31def7 100644 --- a/docs/src/content/docs/reference/imports.md +++ b/docs/src/content/docs/reference/imports.md @@ -84,7 +84,7 @@ Paths are resolved within the `.github` folder. You can specify paths with or wi ## Shared Workflow Components -Files without a trigger event are shared workflow components: they are validated, can be imported by other workflows, and are not compiled into standalone GitHub Actions. Shared components may also define import-safe `on` keys (`ambient-folders`, `skip-if-match`, `skip-if-no-match`, `skip-roles`, `skip-bots`, `github-token`, `github-app`) for reuse through imports. +Files without a trigger event are shared workflow components: they are validated, can be imported by other workflows, and are not compiled into standalone GitHub Actions. Shared components may also define `ambient-folders` and import-safe `on` keys (`skip-if-match`, `skip-if-no-match`, `skip-roles`, `skip-bots`, `github-token`, `github-app`) for reuse through imports. A shared workflow's frontmatter can contain comments only. Comment-only frontmatter is treated as present, so the file still parses as a shared component and produces an empty frontmatter map rather than failing with `no frontmatter found`. Only truly missing or whitespace-only frontmatter is rejected. diff --git a/pkg/constants/job_constants.go b/pkg/constants/job_constants.go index 27dac756d50..d26a938d3fc 100644 --- a/pkg/constants/job_constants.go +++ b/pkg/constants/job_constants.go @@ -149,13 +149,6 @@ const ArtifactPrefixOutputName = "artifact_prefix" // (aw_info.json and prompt.txt). const ActivationArtifactName = "activation" -// ActivationStageAmbientFoldersStepName is the step name used to stage ambient -// folders before the activation artifact is packaged. It is a stable anchor -// used to determine the insertion point for jobs.activation.steps injected -// via built-in job step merging; keep it in sync with any renames of that -// step. -const ActivationStageAmbientFoldersStepName = "Stage ambient folders for activation artifact" - // ActivationUploadArtifactStepName is the step name used to upload the // activation artifact. It is a stable anchor used to determine the insertion // point for jobs.activation.steps injected via built-in job step merging; diff --git a/pkg/parser/content_extractor.go b/pkg/parser/content_extractor.go index 1dc1e6a5300..d3e7a7479ac 100644 --- a/pkg/parser/content_extractor.go +++ b/pkg/parser/content_extractor.go @@ -190,6 +190,36 @@ func extractOnSectionFieldFromMap(frontmatter map[string]any, fieldName string) return string(jsonData), nil } +// extractTopLevelListFieldFromMap extracts a top-level string or array field as a JSON array. +func extractTopLevelListFieldFromMap(frontmatter map[string]any, fieldName string) (string, error) { + fieldValue, exists := frontmatter[fieldName] + if !exists { + return "[]", nil + } + + var normalizedValue []any + switch v := fieldValue.(type) { + case string: + if v != "" { + normalizedValue = []any{v} + } + case []any: + normalizedValue = v + case []string: + for _, s := range v { + normalizedValue = append(normalizedValue, s) + } + default: + return "[]", nil + } + + jsonData, err := json.Marshal(normalizedValue) + if err != nil { + return "[]", nil + } + return string(jsonData), nil +} + // extractOnSectionAnyFieldFromMap extracts a specific field from the on: section in an already-parsed // frontmatter map as a JSON string, handling any value type. // This avoids re-parsing YAML when the frontmatter has already been parsed. diff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go index 86052a05417..5b2370a8d71 100644 --- a/pkg/parser/import_field_extractor.go +++ b/pkg/parser/import_field_extractor.go @@ -486,7 +486,7 @@ func (acc *importAccumulator) appendYAMLBuilderField(fm map[string]any, field st // extractActivationFields extracts activation and authentication-related fields from // the frontmatter map: bots, skip-roles, skip-bots, skip-if-match, skip-if-no-match, -// on.ambient-folders, on.github-token, on.github-app, top-level github-app, and checkout. +// ambient-folders, on.github-token, on.github-app, top-level github-app, and checkout. // // Side effects: acc.bots, acc.botsSet, acc.skipRoles, acc.skipRolesSet, acc.skipBots, // acc.skipBotsSet, acc.skipIfMatch, acc.skipIfNoMatch, acc.activationGitHubToken, @@ -517,7 +517,7 @@ func (acc *importAccumulator) mergeSkipBots(fm map[string]any) { } func (acc *importAccumulator) mergeAmbientFolders(fm map[string]any) { - mergeJSONStringListField(fm, "ambient-folders", "[]", acc.ambientFoldersSet, &acc.ambientFolders, extractOnSectionFieldFromMap) + mergeJSONStringListField(fm, "ambient-folders", "[]", acc.ambientFoldersSet, &acc.ambientFolders, extractTopLevelListFieldFromMap) } func mergeJSONStringListField( diff --git a/pkg/parser/import_field_extractor_test.go b/pkg/parser/import_field_extractor_test.go index aab2a4f8763..2dc4adbd600 100644 --- a/pkg/parser/import_field_extractor_test.go +++ b/pkg/parser/import_field_extractor_test.go @@ -251,19 +251,17 @@ func TestAmbientFoldersExtractedFromMdImport(t *testing.T) { sharedDir := filepath.Join(tmpDir, "shared") require.NoError(t, os.MkdirAll(sharedDir, 0755), "Failed to create shared dir") require.NoError(t, os.WriteFile(filepath.Join(sharedDir, "first.md"), []byte(`--- -on: - ambient-folders: - - .squad - - .github/agents +ambient-folders: + - .squad + - .github/agents --- # First shared workflow `), 0644), "Failed to write first shared file") require.NoError(t, os.WriteFile(filepath.Join(sharedDir, "second.md"), []byte(`--- -on: - ambient-folders: - - .squad - - .config/agents +ambient-folders: + - .squad + - .config/agents --- # Second shared workflow diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index 5703c590fff..dee5d76d2c7 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -48,7 +48,7 @@ type ImportsResult struct { MergedSkipBots []string // Merged skip-bots list from all imports (union of usernames) MergedSkipIfMatch string // on.skip-if-match from first imported workflow that defines it (JSON-encoded) MergedSkipIfNoMatch string // on.skip-if-no-match from first imported workflow that defines it (JSON-encoded) - MergedAmbientFolders []string // Merged on.ambient-folders list from all imports (union, deduplicated) + MergedAmbientFolders []string // Merged ambient-folders list from all imports (union, deduplicated) MergedActivationGitHubToken string // GitHub token from on.github-token in first imported workflow that defines it MergedActivationGitHubApp string // JSON-encoded on.github-app from first imported workflow that defines it MergedTopLevelGitHubApp string // JSON-encoded top-level github-app from first imported workflow that defines it diff --git a/pkg/parser/schema_validation.go b/pkg/parser/schema_validation.go index 76fa6735e34..37f5bbdbc9d 100644 --- a/pkg/parser/schema_validation.go +++ b/pkg/parser/schema_validation.go @@ -17,7 +17,6 @@ var schemaValidationLog = logger.New("parser:schema_validation") var sharedWorkflowForbiddenFields = buildForbiddenFieldsMap() var sharedWorkflowAllowedOnFieldList = []string{ - "ambient-folders", "skip-if-match", "skip-if-no-match", "skip-roles", @@ -27,7 +26,6 @@ var sharedWorkflowAllowedOnFieldList = []string{ } var sharedWorkflowAllowedOnFields = map[string]struct{}{ - "ambient-folders": {}, "skip-if-match": {}, "skip-if-no-match": {}, "skip-roles": {}, diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 2ccaadd866f..81a3f67d3cf 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -56,6 +56,30 @@ ["ci", "testing"] ] }, + "ambient-folders": { + "type": "array", + "description": "Workspace-relative folders to include in the activation artifact. Useful for activation steps that generate reusable prompt, skill, or agent context.", + "items": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9._/\\-]+$", + "not": { + "anyOf": [ + { + "pattern": "^/" + }, + { + "pattern": "(^|/)\\.\\.(/|$)" + }, + { + "pattern": "^\\.$" + } + ] + } + }, + "uniqueItems": true, + "examples": [[".squad"], [".squad", ".github/agents"]] + }, "skills": { "type": "array", "description": "Optional list of skill references to install during activation. Supports remote repository-wide installs (`owner/repo@`), remote path-scoped installs (`owner/repo/skill/path@`), and local path references (e.g. `skills/rig` or `.github/skills/my-skill`). Remote static references must be pinned to a full 40-character lowercase commit SHA. Local paths are installed with --from-local at runtime and are rewritten to a remote repospec by `gh aw add`. GitHub Actions expressions (`${{ ... }}`) are also accepted and are evaluated at runtime. Entries may also be objects to configure per-skill authentication via github-token or github-app.", @@ -2054,30 +2078,6 @@ "type": "boolean", "description": "Allow the bot-posted-menu / user-checks-box pattern: when a workflow posts a checkbox-menu comment as a GitHub App bot and a human maintainer edits it to tick a box (issue_comment:edited where actor \u2260 comment.user.login), treat this as safe and skip the confused-deputy check. When false (default), the check applies to all issue_comment events. The Dependabot confused-deputy attack vector (issue_comment:created) is unaffected." }, - "ambient-folders": { - "type": "array", - "description": "Workspace-relative folders to bundle in the activation artifact and restore before the agent runs. Useful for activation steps that generate reusable prompt, skill, or agent context.", - "items": { - "type": "string", - "minLength": 1, - "pattern": "^[A-Za-z0-9._/\\-]+$", - "not": { - "anyOf": [ - { - "pattern": "^/" - }, - { - "pattern": "(^|/)\\.\\.(/|$)" - }, - { - "pattern": "^\\.$" - } - ] - } - }, - "uniqueItems": true, - "examples": [[".squad"], [".squad", ".github/agents"]] - }, "manual-approval": { "type": "string", "description": "Environment name that requires manual approval before the workflow can run. Must match a valid environment configured in the repository settings." diff --git a/pkg/workflow/ambient_folders.go b/pkg/workflow/ambient_folders.go index 135200462a5..209273dc6ee 100644 --- a/pkg/workflow/ambient_folders.go +++ b/pkg/workflow/ambient_folders.go @@ -7,7 +7,6 @@ import ( "regexp" "strings" - "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/parser" ) @@ -27,11 +26,7 @@ func resolveAmbientFolders(frontmatter map[string]any, importsResult *parser.Imp } func extractAmbientFolders(frontmatter map[string]any) ([]string, error) { - onMap := ensureOnMap(frontmatter) - if onMap == nil { - return nil, nil - } - raw, exists := onMap["ambient-folders"] + raw, exists := frontmatter["ambient-folders"] if !exists || raw == nil { return nil, nil } @@ -43,14 +38,14 @@ func extractAmbientFolders(frontmatter map[string]any) ([]string, error) { values = append(values, value) } } else { - return nil, errors.New("on.ambient-folders must be an array of folder paths") + return nil, errors.New("ambient-folders must be an array of folder paths") } } folders := make([]string, 0, len(values)) for _, value := range values { folder, ok := value.(string) if !ok { - return nil, errors.New("on.ambient-folders entries must be strings") + return nil, errors.New("ambient-folders entries must be strings") } folders = append(folders, folder) } @@ -63,14 +58,14 @@ func normalizeAmbientFolders(folders []string) ([]string, error) { for _, folder := range folders { value := strings.TrimSpace(strings.ReplaceAll(folder, "\\", "/")) if value == "" { - return nil, errors.New("on.ambient-folders entries cannot be empty") + return nil, errors.New("ambient-folders entries cannot be empty") } clean := filepath.ToSlash(filepath.Clean(value)) if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || filepath.IsAbs(value) || strings.HasPrefix(value, "/") { - return nil, fmt.Errorf("on.ambient-folders entry %q must be a relative folder path within the repository", folder) + return nil, fmt.Errorf("ambient-folders entry %q must be a relative folder path within the repository", folder) } if !ambientFolderPattern.MatchString(clean) { - return nil, fmt.Errorf("on.ambient-folders entry %q contains unsupported characters", folder) + return nil, fmt.Errorf("ambient-folders entry %q contains unsupported characters", folder) } if _, exists := seen[clean]; exists { continue @@ -80,56 +75,3 @@ func normalizeAmbientFolders(folders []string) ([]string, error) { } return normalized, nil } - -func generateStageAmbientFoldersStep(data *WorkflowData) []string { - if data == nil || len(data.AmbientFolders) == 0 { - return nil - } - folders := strings.Join(data.AmbientFolders, " ") - return []string{ - " - name: " + constants.ActivationStageAmbientFoldersStepName + "\n", - " env:\n", - fmt.Sprintf(" GH_AW_AMBIENT_FOLDERS: \"%s\"\n", folders), - " # poutine:ignore untrusted_checkout_exec\n", - " run: |\n", - " mkdir -p /tmp/gh-aw/ambient-folders\n", - " for folder in $GH_AW_AMBIENT_FOLDERS; do\n", - " src=\"$GITHUB_WORKSPACE/$folder\"\n", - " dst=\"/tmp/gh-aw/ambient-folders/$folder\"\n", - " if [ -e \"$src\" ]; then\n", - " mkdir -p \"$(dirname \"$dst\")\"\n", - " rm -rf \"$dst\"\n", - " cp -a \"$src\" \"$dst\"\n", - " fi\n", - " done\n", - } -} - -func generateRestoreAmbientFoldersStep(yaml *strings.Builder, data *WorkflowData) { - for _, line := range restoreAmbientFoldersSteps(data) { - yaml.WriteString(line) - yaml.WriteByte('\n') - } -} - -func restoreAmbientFoldersSteps(data *WorkflowData) GitHubActionStep { - if data == nil || len(data.AmbientFolders) == 0 { - return nil - } - return GitHubActionStep{ - " - name: Restore ambient folders from activation artifact", - " env:", - fmt.Sprintf(" GH_AW_AMBIENT_FOLDERS: \"%s\"", strings.Join(data.AmbientFolders, " ")), - " # poutine:ignore untrusted_checkout_exec", - " run: |", - " for folder in $GH_AW_AMBIENT_FOLDERS; do", - " src=\"/tmp/gh-aw/ambient-folders/$folder\"", - " dst=\"$GITHUB_WORKSPACE/$folder\"", - " if [ -e \"$src\" ]; then", - " mkdir -p \"$(dirname \"$dst\")\"", - " rm -rf \"$dst\"", - " cp -a \"$src\" \"$dst\"", - " fi", - " done", - } -} diff --git a/pkg/workflow/compiler_activation_outputs.go b/pkg/workflow/compiler_activation_outputs.go index 30f690c9c5b..176c0215461 100644 --- a/pkg/workflow/compiler_activation_outputs.go +++ b/pkg/workflow/compiler_activation_outputs.go @@ -155,7 +155,6 @@ func (c *Compiler) configureActivationNeedsAndCondition(ctx *activationJobBuildC func (c *Compiler) addActivationArtifactUploadStep(ctx *activationJobBuildContext) { compilerActivationJobLog.Print("Adding activation artifact upload step") activationArtifactName := artifactPrefixExprForActivationJob(ctx.data) + constants.ActivationArtifactName - ctx.steps = append(ctx.steps, generateStageAmbientFoldersStep(ctx.data)...) ctx.steps = append(ctx.steps, " - name: "+constants.ActivationUploadArtifactStepName+"\n") ctx.steps = append(ctx.steps, " if: success()\n") ctx.steps = append(ctx.steps, fmt.Sprintf(" uses: %s\n", c.getActionPin("actions/upload-artifact"))) @@ -170,8 +169,8 @@ func (c *Compiler) addActivationArtifactUploadStep(ctx *activationJobBuildContex ctx.steps = append(ctx.steps, " /tmp/gh-aw/aw-prompts/prompt-import-tree.json\n") ctx.steps = append(ctx.steps, " /tmp/gh-aw/"+constants.GithubRateLimitsFilename+"\n") ctx.steps = append(ctx.steps, " /tmp/gh-aw/base\n") - if len(ctx.data.AmbientFolders) > 0 { - ctx.steps = append(ctx.steps, " /tmp/gh-aw/ambient-folders\n") + for _, folder := range ctx.data.AmbientFolders { + ctx.steps = append(ctx.steps, " "+folder+"\n") } engineID := resolveActivationEngineID(ctx.data) // Include the engine-specific sub-agent staging directory only when inline agents are enabled. diff --git a/pkg/workflow/compiler_artifacts_test.go b/pkg/workflow/compiler_artifacts_test.go index a944e42b0b3..50b5e44c3ad 100644 --- a/pkg/workflow/compiler_artifacts_test.go +++ b/pkg/workflow/compiler_artifacts_test.go @@ -324,9 +324,8 @@ func TestAmbientFoldersIncludedInActivationArtifact(t *testing.T) { t.Fatal(err) } sharedContent := `--- -on: - ambient-folders: - - .squad +ambient-folders: + - .squad jobs: activation: pre-steps: @@ -371,11 +370,8 @@ imports: lockYAML := string(lockContent) for _, want := range []string{ - "Stage ambient folders for activation artifact", - "GH_AW_AMBIENT_FOLDERS: \".squad\"", - "/tmp/gh-aw/ambient-folders", - "Restore ambient folders from activation artifact", - ".squad", + "path: /", + " .squad", } { if !strings.Contains(lockYAML, want) { t.Fatalf("Expected compiled workflow to contain %q, got:\n%s", want, lockYAML) @@ -390,9 +386,8 @@ func TestAmbientFoldersRestoredAfterCustomCheckout(t *testing.T) { t.Fatal(err) } sharedContent := `--- -on: - ambient-folders: - - .squad +ambient-folders: + - .squad jobs: activation: pre-steps: @@ -442,12 +437,12 @@ steps: if checkoutIndex == -1 { t.Fatalf("Expected custom checkout step in compiled workflow, got:\n%s", lockYAML) } - restoreIndex := strings.LastIndex(lockYAML, "Restore ambient folders from activation artifact") - if restoreIndex == -1 { - t.Fatalf("Expected ambient restore step in compiled workflow, got:\n%s", lockYAML) + downloadIndex := strings.Index(lockYAML, "name: Download activation artifact") + if downloadIndex == -1 { + t.Fatalf("Expected activation artifact download step in compiled workflow, got:\n%s", lockYAML) } - if restoreIndex < checkoutIndex { - t.Fatalf("Expected final ambient restore after custom checkout; checkout index %d, restore index %d", checkoutIndex, restoreIndex) + if downloadIndex > checkoutIndex { + t.Fatalf("Expected activation artifact download before custom checkout; download index %d, checkout index %d", downloadIndex, checkoutIndex) } } diff --git a/pkg/workflow/compiler_custom_jobs.go b/pkg/workflow/compiler_custom_jobs.go index 057517d11c1..773afd0be64 100644 --- a/pkg/workflow/compiler_custom_jobs.go +++ b/pkg/workflow/compiler_custom_jobs.go @@ -677,8 +677,7 @@ func insertActivationStepsBeforeArtifactStaging(jobName string, steps []string, insertIdx := len(steps) for i, step := range steps { - if strings.Contains(step, "name: "+constants.ActivationStageAmbientFoldersStepName) || - strings.Contains(step, "name: "+constants.ActivationUploadArtifactStepName) { + if strings.Contains(step, "name: "+constants.ActivationUploadArtifactStepName) { insertIdx = i break } diff --git a/pkg/workflow/compiler_custom_jobs_test.go b/pkg/workflow/compiler_custom_jobs_test.go index 19249951188..4cf282f79d5 100644 --- a/pkg/workflow/compiler_custom_jobs_test.go +++ b/pkg/workflow/compiler_custom_jobs_test.go @@ -1019,8 +1019,6 @@ func TestInsertActivationStepsBeforeArtifactStaging(t *testing.T) { steps := []string{ " - name: Generate prompt\n", " run: echo prompt\n", - " - name: Stage ambient folders for activation artifact\n", - " run: echo stage\n", " - name: Upload activation artifact\n", " run: echo upload\n", } @@ -1032,13 +1030,10 @@ func TestInsertActivationStepsBeforeArtifactStaging(t *testing.T) { result := insertActivationStepsBeforeArtifactStaging(string(constants.ActivationJobName), steps, activationSteps) squadIndex := indexOfStep(result, "Initialize Squad team") - stageIndex := indexOfStep(result, "Stage ambient folders for activation artifact") uploadIndex := indexOfStep(result, "Upload activation artifact") require.NotEqual(t, -1, squadIndex) - require.NotEqual(t, -1, stageIndex) require.NotEqual(t, -1, uploadIndex) - assert.Less(t, squadIndex, stageIndex, "activation steps should run before ambient folders are staged") - assert.Less(t, stageIndex, uploadIndex, "ambient folders should still stage before upload") + assert.Less(t, squadIndex, uploadIndex, "activation steps should run before artifact upload") } func indexOfStep(steps []string, needle string) int { diff --git a/pkg/workflow/compiler_yaml_ai_execution.go b/pkg/workflow/compiler_yaml_ai_execution.go index 0fd96739651..84f61e0b855 100644 --- a/pkg/workflow/compiler_yaml_ai_execution.go +++ b/pkg/workflow/compiler_yaml_ai_execution.go @@ -355,7 +355,6 @@ func (c *Compiler) generateEngineInstallAndPreAgentSteps(yaml *strings.Builder, registry.GetAllAgentManifestFolders(), registry.GetAllAgentManifestFiles(), ) - generateRestoreAmbientFoldersStep(yaml, data) } // Restore inline sub-agents written during the activation job. diff --git a/pkg/workflow/compiler_yaml_runtime_setup.go b/pkg/workflow/compiler_yaml_runtime_setup.go index 4f27097861b..f21f6118659 100644 --- a/pkg/workflow/compiler_yaml_runtime_setup.go +++ b/pkg/workflow/compiler_yaml_runtime_setup.go @@ -200,15 +200,12 @@ func (c *Compiler) emitCustomSteps(yaml *strings.Builder, data *WorkflowData, cu customStepsToEmit = injectProxyEnvIntoCustomSteps(customStepsToEmit) } postLastCheckoutSteps := sharedLogsCacheRestoreSteps(data) - if ambientRestoreStep := restoreAmbientFoldersSteps(data); len(ambientRestoreStep) > 0 { - postLastCheckoutSteps = append(postLastCheckoutSteps, ambientRestoreStep) - } if customStepsContainCheckout && (len(runtimeSetupSteps) > 0 || len(postLastCheckoutSteps) > 0) { // Custom steps contain checkout: insert runtime steps after the first checkout and // workspace restore steps after the last checkout. Inserting restore steps after the // last checkout ensures that a multi-checkout custom steps block (where a later root - // checkout would wipe .github/aw/logs or ambient folders) leaves restored content in - // place for later custom steps and the agent. + // checkout would wipe .github/aw/logs) leaves restored content in place for later + // custom steps and the agent. compilerYamlLog.Printf("Calling addCustomStepsWithRuntimeInsertion: %d runtime steps after first checkout, %d post-checkout steps after last checkout", len(runtimeSetupSteps), len(postLastCheckoutSteps)) c.addCustomStepsWithRuntimeInsertion(yaml, customStepsToEmit, runtimeSetupSteps, postLastCheckoutSteps, data.ParsedTools, isArcDindTopology(data)) } else { @@ -238,8 +235,11 @@ func (c *Compiler) generateActivationArtifactAndCommentMemorySteps(yaml *strings fmt.Fprintf(yaml, " uses: %s\n", c.getActionPin("actions/download-artifact")) yaml.WriteString(" with:\n") fmt.Fprintf(yaml, " name: %s\n", activationArtifactName) - yaml.WriteString(" path: /tmp/gh-aw\n") - generateRestoreAmbientFoldersStep(yaml, data) + if len(data.AmbientFolders) > 0 { + yaml.WriteString(" path: /\n") + } else { + yaml.WriteString(" path: /tmp/gh-aw\n") + } // Materialize comment-memory safe outputs as editable markdown files BEFORE user steps. // This prepares /tmp/gh-aw/comment-memory/*.md from prior comment history and injects diff --git a/pkg/workflow/event_validation.go b/pkg/workflow/event_validation.go index 9a96eeb038c..7480e2c26d8 100644 --- a/pkg/workflow/event_validation.go +++ b/pkg/workflow/event_validation.go @@ -82,7 +82,6 @@ var validGitHubEventTypes = []string{ // type validation. var ghAwOnSectionKeys = map[string]bool{ "allow-bot-authored-trigger-comment": true, - "ambient-folders": true, "bots": true, "command": true, "github-app": true,