Skip to content

Commit a7e788c

Browse files
Copilotpelikhangithub-actions[bot]claudegh-aw-bot
authored
Support package resources with scoped ownership (#54120)
* Initial plan * Add package resource ownership support Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * docs(adr): add draft ADR-54120 for package resources with scoped ownership Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: harden package resource update accounting and rollback Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> * chore: start conflict resolution pass Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * docs: update repository package docs and manifest spec Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * fix: resolve lint-go and package resources mapping test failures Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> Co-authored-by: Peli de Halleux <pelikhan@users.noreply.github.com>
1 parent 5d3dbf0 commit a7e788c

19 files changed

Lines changed: 1054 additions & 17 deletions
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# ADR-54120: Package Resources with Scoped Ownership
2+
3+
**Date**: 2026-08-20
4+
**Status**: Draft
5+
**Deciders**: pelikhan, copilot-swe-agent
6+
7+
---
8+
9+
### Context
10+
11+
Repository packages (`aw.yml` manifests) could install workflows, skills, and agents, but had no mechanism to bundle supplementary repository assets such as Issue Forms (`.github/ISSUE_TEMPLATE/*.yml`), `CODEOWNERS`, or policy files under `.github/aw/`. Consumers who needed these files had to copy them manually alongside `gh aw add`, breaking the self-contained package installation model. The gap was tracked in issue #52769.
12+
13+
### Decision
14+
15+
We will introduce a `resources:` field in the `aw.yml` package manifest. Each resource entry declares a package-relative `source` and a repository-root-relative `destination`. Destinations are restricted to an explicit allowlist (`.github/ISSUE_TEMPLATE/*.yml|*.yaml`, `.github/CODEOWNERS`, `.github/aw/**`). Resources are copied as inert content (no compilation, no secret injection). For every package installation, SHA-256-based ownership records are written under `.github/aw/packages/*.json`, and updates refuse to overwrite locally drifted files unless `--force` is passed. Stale resource files are removed on `gh aw update` when they are dropped from the manifest and unchanged since installation.
16+
17+
### Alternatives Considered
18+
19+
#### Alternative 1: Extend the existing `includes` / `files` field
20+
21+
The `includes` field already supports explicit source-to-destination mappings for installable workflow files. Resources could be added there with a special flag or naming convention distinguishing inert-copy from compiled-workflow semantics.
22+
23+
Rejected because mixing the two installation modes in one field creates ambiguity: `includes` entries go through compilation and `.md`-to-workflow translation steps that are inappropriate for raw YAML or JSON assets. Adding a discriminant flag would complicate the schema and parser without a natural extension point.
24+
25+
#### Alternative 2: Document-only / manual copy instructions
26+
27+
Packages could document supplementary files in their README and expect users to copy them manually. This preserves simplicity in the CLI.
28+
29+
Rejected because it breaks the single-command (`gh aw add`) installation promise and requires package consumers to know which files to copy, defeating the purpose of a manifest-driven package system.
30+
31+
### Consequences
32+
33+
#### Positive
34+
- Packages can ship a complete, self-contained repository setup — workflows, skills, agents, issue templates, CODEOWNERS, and policy files — in one `gh aw add` invocation.
35+
- SHA-256 ownership records prevent silent overwrites of locally modified files, making update safety explicit and auditable.
36+
- The allowlist of valid destinations prevents packages from writing to arbitrary repository paths, limiting the blast radius of a malicious or misconfigured package.
37+
38+
#### Negative
39+
- The destination allowlist (ISSUE_TEMPLATE, CODEOWNERS, `.github/aw/**`) must be maintained as product needs evolve; adding new allowed namespaces requires a code change and specification update.
40+
- Packages that declare only `resources:` (no workflows/skills/agents) are now valid, which changes the emptiness check in `resolveRepositoryPackage` and may surface unexpected edge cases in tooling that assumes at least one workflow is present.
41+
42+
#### Neutral
43+
- A new `IsPackageResourceFile` discriminant is added to `WorkflowSpec` and `ResolvedWorkflow`; bootstrap profile helpers skip resource files when inferring Copilot Auth and GitHub App permission requirements, consistent with how skill and agent files are handled.
44+
- The `gh aw remove` command gains cleanup logic that removes package-owned resource files when the last workflow from a package is removed and the files are still unchanged since installation.
45+
46+
---
47+
48+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

docs/src/content/docs/reference/repository-package-manifest.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,16 @@ emoji: 🤖
2222
description: Friendly repository automation for review and issue triage
2323
includes:
2424
- workflows/review.md # agentic workflow — compiled on install
25+
- source: templates/triage.yml # explicit source/destination mapping
26+
destination: .github/workflows/triage.yml
2527
- skills/code-review # skill directory (must contain SKILL.md)
2628
- agents/reviewer.md # agent file
2729
- .github/workflows/ci.yml # raw Actions YAML — copied verbatim
30+
resources:
31+
- source: templates/bug.yml
32+
destination: .github/ISSUE_TEMPLATE/bug.yml
33+
- source: policy/controls.json
34+
destination: .github/aw/policy/controls.json
2835
```
2936
3037
## Quick reference
@@ -36,7 +43,8 @@ includes:
3643
| `name` | string | Yes | Human-readable package name. Must be non-empty after trimming whitespace. |
3744
| `emoji` | string | No | Optional package emoji for display in package metadata. |
3845
| `description` | string | No | Optional package description. `gh aw add` warns when it exceeds 255 characters. |
39-
| `includes` | array of strings | No | Package-root-relative paths. Type is inferred from folder naming: workflows (`workflows/`, `agentic-workflows/`, `.github/workflows/`), skills (`skills/`, `.github/skills/`), agents (`agents/`, `.github/agents/`). |
46+
| `includes` | array of strings or mappings | No | Explicit install entries. Strings use path conventions; mappings declare package-relative `source` and repository-root-relative `destination` for workflow installs under `.github/workflows/`. |
47+
| `resources` | array of mappings | No | Declarative repository assets copied as-is from package content to allowlisted destinations. |
4048
| `files` | array of strings | No | Deprecated alias. Use `includes` instead. |
4149

4250
## Documentation
@@ -60,11 +68,28 @@ If `includes` is present, valid entries are used as the install bundle. Supporte
6068
- **Skills** — directory paths under `skills/` or `.github/skills/` that contain `SKILL.md`.
6169
- **Agents** — `.md` files under `agents/` or `.github/agents/`.
6270

71+
Mapping entries in `includes` can install workflow files from package-relative `source` paths into explicit repository-root `destination` paths under `.github/workflows/`.
72+
73+
## Resources
74+
75+
`resources` installs inert repository assets from package content:
76+
77+
- `source` is always package-root-relative.
78+
- `destination` is always repository-root-relative.
79+
- Allowed destination namespaces:
80+
- `.github/ISSUE_TEMPLATE/*.{yml,yaml}` (direct children only; nested subdirectories such as `.github/ISSUE_TEMPLATE/bug_report/bug.yml` fail manifest validation)
81+
- `.github/CODEOWNERS`
82+
- `.github/aw/**`
83+
84+
`gh aw add` rejects duplicate (including case-insensitive duplicate) resource destinations, path traversal, and non-regular local resource sources. Installed resources are tracked with package-scoped ownership metadata in `.github/aw/packages/*.json`.
85+
6386
If `includes` is omitted or contains no valid workflow paths, `gh aw add` scans:
6487

6588
- `workflows/`
6689
- `.github/workflows/`
6790

6891
For nested packages, those paths are resolved relative to the package root.
6992

93+
Packages can still be valid without workflows when they declare installable `resources`, skills, or agents.
94+
7095
The embedded JSON schema source of truth lives in `pkg/parser/schemas/aw_manifest_schema.json`.

docs/src/content/docs/specs/repository-package-manifest-specification.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ sidebar:
77

88
# aw.yml Repository Package Manifest Specification
99

10-
**Version**: 0.2.0
10+
**Version**: 0.2.1
1111
**Status**: Draft
1212

1313
## Abstract
@@ -49,6 +49,7 @@ The manifest document MUST be a YAML mapping. Unknown top-level fields MUST be r
4949
| `license` | string | No | SPDX license identifier or license name for the package. |
5050
| `files` | array of strings | No | Deprecated. Explicit installable workflow file list. Use `includes` instead. |
5151
| `includes` | array of strings or mappings | No | Explicit installable package entries. String entries use path conventions; mapping entries declare an explicit source-to-destination install path. |
52+
| `resources` | array of mappings | No | Declarative repository assets copied as-is to allowlisted destinations. |
5253

5354
### 4.2 `manifest-version`
5455

@@ -126,6 +127,28 @@ Mapping entries follow the same install semantics as string entries: `.md` sourc
126127

127128
`gh aw add`, `gh aw add-wizard`, and `gh aw update` MUST use identical mapping semantics, and `gh aw update` MUST continue to track the manifest source of installed files.
128129

130+
### 4.10 `resources`
131+
132+
If present, `resources` MUST be an array of mappings. Each mapping MUST contain:
133+
134+
| Key | Type | Required | Meaning |
135+
| --- | --- | --- | --- |
136+
| `source` | string | Yes | Package-relative path of the asset to copy. |
137+
| `destination` | string | Yes | Repository-root-relative destination path. |
138+
139+
Resource `source` and `destination` values MUST NOT be absolute paths and MUST NOT escape their roots through path traversal. Local package sources MUST NOT be symbolic links, directories, or other non-regular file replacements.
140+
141+
Resource destinations are restricted to non-hook repository asset namespaces:
142+
143+
- `.github/ISSUE_TEMPLATE/*.yml`
144+
- `.github/ISSUE_TEMPLATE/*.yaml`
145+
- `.github/CODEOWNERS`
146+
- `.github/aw/**`
147+
148+
Implementations MUST reject duplicate or case-insensitive duplicate resource destinations before writing files. Resources are copied as inert content from the selected package ref; installers MUST NOT execute package-provided scripts or expose configured secrets to package content during installation.
149+
150+
For each package installation, implementations MUST record package-scoped ownership metadata under `.github/aw/packages/`. The record MUST identify the package source, resolved immutable commit/ref, installed destination paths, source paths, and SHA-256 content digests. Implementations MUST refuse to overwrite existing resource files unless they are unchanged files owned by the same package, or unless the user explicitly passes `--force`.
151+
129152
## 5. Installable file resolution
130153

131154
Supported installable paths are:
@@ -149,7 +172,7 @@ If `files` is omitted, or if no valid entries remain after filtering, the implem
149172

150173
Auto-discovery considers only agentic workflow markdown (`.md`); raw `.yml` action workflows MUST be referenced explicitly in `files` to be installed.
151174

152-
If no installable workflow files are resolved, package validation MUST fail.
175+
If no installable package assets are resolved (workflows, resources, skills, or agents), package validation MUST fail.
153176

154177
### 5.1 Install
155178

@@ -159,7 +182,8 @@ The install lifecycle (invoked by `gh aw add`) MUST proceed in the following ord
159182
2. **Resolve** the installable file list per §5.
160183
3. **Download** each resolved file from the remote package source.
161184
4. **Compile** each agentic workflow markdown file into the target repository's workflow directory. Raw `.yml` files are copied verbatim without compilation.
162-
5. **Write** all output files atomically before reporting success.
185+
5. **Copy** declared `resources` as inert repository assets without executing them.
186+
6. **Write** all output files and package ownership metadata atomically before reporting success.
163187

164188
If any step fails, the implementation MUST abort and MUST NOT leave partial output files in the target directory. The implementation SHOULD emit an actionable error identifying the failing step. See §10 (Safeguards) for the normative rollback and permission-error requirements that apply to this lifecycle (R-PKG-003, R-PKG-004, R-PKG-006, R-PKG-007).
165189

@@ -169,7 +193,7 @@ The update lifecycle re-installs a package at a newer (or specified) version, ov
169193

170194
**R-PKG-U001**: `gh aw add` with a version specifier (e.g., `owner/repo@v2.0.0`) MUST overwrite previously installed files from the same package with the new version's files, following the same install ordering defined in §5.1.
171195

172-
**R-PKG-U002**: Files that were present in the previous installation but are absent from the new version's resolved file list MUST be left in place. The implementation SHOULD emit a warning for each such orphaned file, identifying the file by path and noting that it was not present in the new version.
196+
**R-PKG-U002**: Files that were present in the previous installation but are absent from the new version's resolved package-managed file list MUST be removed only when all of the following hold: (a) they are owned by the same package, (b) they are unchanged from the recorded digest, and (c) no replacement from the new version maps to the same path. When a new version entry maps to the same path, overwrite behavior is governed by R-PKG-U001. Implementations SHOULD warn when stale files are preserved because they were modified or ownership cannot be proven.
173197

174198
**R-PKG-U003**: If overwriting a file fails (for example, due to a filesystem permission error or a locked file), the implementation MUST abort the update and MUST NOT leave the target directory in a mixed state combining old and new file versions. The implementation MUST emit an error identifying the file that could not be overwritten and the reason.
175199

@@ -213,7 +237,7 @@ Validation MUST fail for at least the following conditions:
213237
- current compiler version is lower than `min-version`;
214238
- unknown top-level fields, including `docs`; or
215239
- missing required `README.md`; or
216-
- no installable workflow files resolved.
240+
- no installable package assets (workflows, resources, skills, or agents) resolved.
217241

218242
Implementations SHOULD emit warnings for at least the following conditions:
219243

package.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ emoji: 🤖
4949
files:
5050
- workflows/example.md
5151
- .github/workflows/repo-workflow.md
52+
resources:
53+
- source: templates/bug.yml
54+
destination: .github/ISSUE_TEMPLATE/bug.yml
5255
```
5356
5457
Requirements:
@@ -58,6 +61,7 @@ Requirements:
5861
- `description`: concise and relevant to the actual workflows
5962
- `emoji`: optional package emoji (string)
6063
- `files`: complete list of installable agentic/shared workflows in this repository
64+
- `resources`: optional package-root-relative assets copied to allowlisted destinations such as `.github/ISSUE_TEMPLATE/*.yml`, `.github/CODEOWNERS`, or `.github/aw/**`
6165
- File paths must be package-root-relative and point to existing markdown workflow files under `workflows/` or `.github/workflows/`
6266

6367
Do not invent custom package metadata fields.

pkg/cli/add_command.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,15 @@ func addWorkflowsWithTracking(ctx context.Context, workflows []*ResolvedWorkflow
365365
}
366366
}
367367

368+
if err := writePackageOwnershipRecords(workflows, tracker, opts); err != nil {
369+
if tracker != nil {
370+
if rollbackErr := tracker.RollbackAllFiles(opts.Verbose); rollbackErr != nil {
371+
return fmt.Errorf("failed to write package ownership records (rollback also failed): %w", errors.Join(err, rollbackErr))
372+
}
373+
}
374+
return err
375+
}
376+
368377
if !opts.Quiet && len(workflows) > 1 {
369378
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully added all %d workflows", len(workflows))))
370379
}
@@ -406,6 +415,10 @@ func addWorkflowWithTracking(ctx context.Context, resolved *ResolvedWorkflow, tr
406415
if resolved.IsPackageAgentFile {
407416
return addAgentFileWithTracking(resolved, tracker, opts, gitRoot)
408417
}
418+
// Package resources are copied as-is to their declared repository-relative destinations.
419+
if resolved.IsPackageResourceFile {
420+
return addResourceFileWithTracking(resolved, tracker, opts, gitRoot)
421+
}
409422
sourceRepo := ""
410423
if sourceInfo != nil && !sourceInfo.IsLocal {
411424
sourceRepo = workflowSpec.RepoSlug
@@ -469,6 +482,49 @@ func validateWorkflowDestination(githubWorkflowsDir, workflowName, sourceRepo st
469482
return false, fmt.Errorf("workflow '%s' already exists in .github/workflows/. Use a different name with -n flag, remove the existing workflow first, or use --force to overwrite", workflowName)
470483
}
471484

485+
func addResourceFileWithTracking(resolved *ResolvedWorkflow, tracker *FileTracker, opts AddOptions, gitRoot string) error {
486+
destination := filepath.Clean(filepath.FromSlash(resolved.Spec.DestinationPath))
487+
if destination == "." || filepath.IsAbs(destination) || strings.HasPrefix(destination, ".."+string(os.PathSeparator)) {
488+
return fmt.Errorf("resource destination %q is invalid", resolved.Spec.DestinationPath)
489+
}
490+
destFile := filepath.Join(gitRoot, destination)
491+
rel, err := filepath.Rel(gitRoot, destFile)
492+
if err != nil {
493+
return fmt.Errorf("failed to validate resource destination %q: %w", resolved.Spec.DestinationPath, err)
494+
}
495+
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
496+
return fmt.Errorf("resource destination %q escapes repository root", resolved.Spec.DestinationPath)
497+
}
498+
499+
fileExists := fileutil.FileExists(destFile)
500+
if fileExists && !opts.Force {
501+
packageSource := packageSourceForSpec(resolved.Spec, resolved.SourceInfo)
502+
if owned, drifted := packageOwnershipAllowsOverwrite(gitRoot, rel, packageSource); !owned || drifted {
503+
if owned {
504+
return fmt.Errorf("resource %q has local modifications; use --force to overwrite", resolved.Spec.DestinationPath)
505+
}
506+
return fmt.Errorf("resource %q already exists; use --force to overwrite", resolved.Spec.DestinationPath)
507+
}
508+
}
509+
if err := os.MkdirAll(filepath.Dir(destFile), constants.DirPermPublic); err != nil {
510+
return fmt.Errorf("failed to create resource directory %s: %w", filepath.Dir(destFile), err)
511+
}
512+
if tracker != nil {
513+
if fileExists {
514+
tracker.TrackModified(destFile)
515+
} else {
516+
tracker.TrackCreated(destFile)
517+
}
518+
}
519+
if err := os.WriteFile(destFile, resolved.Content, constants.FilePermPublic); err != nil {
520+
return fmt.Errorf("failed to write resource file %q: %w", destFile, err)
521+
}
522+
if !opts.Quiet {
523+
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Added resource: "+filepath.ToSlash(rel)))
524+
}
525+
return nil
526+
}
527+
472528
func compileAddedWorkflow(ctx context.Context, destFile string, workflowSpec *WorkflowSpec, githubWorkflowsDir string, tracker *FileTracker, opts AddOptions) {
473529
// For remote workflows: now that the main workflow and all its imports are on disk,
474530
// parse the fully merged safe-outputs configuration to discover any dispatch or

0 commit comments

Comments
 (0)