Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/adr/54120-package-resources-with-scoped-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# ADR-54120: Package Resources with Scoped Ownership

**Date**: 2026-08-20
**Status**: Draft
**Deciders**: pelikhan, copilot-swe-agent

---

### Context

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.

### Decision

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.

### Alternatives Considered

#### Alternative 1: Extend the existing `includes` / `files` field

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.

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.

#### Alternative 2: Document-only / manual copy instructions

Packages could document supplementary files in their README and expect users to copy them manually. This preserves simplicity in the CLI.

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.

### Consequences

#### Positive
- Packages can ship a complete, self-contained repository setup — workflows, skills, agents, issue templates, CODEOWNERS, and policy files — in one `gh aw add` invocation.
- SHA-256 ownership records prevent silent overwrites of locally modified files, making update safety explicit and auditable.
- The allowlist of valid destinations prevents packages from writing to arbitrary repository paths, limiting the blast radius of a malicious or misconfigured package.

#### Negative
- 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.
- 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.

#### Neutral
- 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.
- 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.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
27 changes: 26 additions & 1 deletion docs/src/content/docs/reference/repository-package-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,16 @@ emoji: 🤖
description: Friendly repository automation for review and issue triage
includes:
- workflows/review.md # agentic workflow — compiled on install
- source: templates/triage.yml # explicit source/destination mapping
destination: .github/workflows/triage.yml
- skills/code-review # skill directory (must contain SKILL.md)
- agents/reviewer.md # agent file
- .github/workflows/ci.yml # raw Actions YAML — copied verbatim
resources:
- source: templates/bug.yml
destination: .github/ISSUE_TEMPLATE/bug.yml
- source: policy/controls.json
destination: .github/aw/policy/controls.json
```

## Quick reference
Expand All @@ -36,7 +43,8 @@ includes:
| `name` | string | Yes | Human-readable package name. Must be non-empty after trimming whitespace. |
| `emoji` | string | No | Optional package emoji for display in package metadata. |
| `description` | string | No | Optional package description. `gh aw add` warns when it exceeds 255 characters. |
| `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/`). |
| `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/`. |
| `resources` | array of mappings | No | Declarative repository assets copied as-is from package content to allowlisted destinations. |
| `files` | array of strings | No | Deprecated alias. Use `includes` instead. |

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

Mapping entries in `includes` can install workflow files from package-relative `source` paths into explicit repository-root `destination` paths under `.github/workflows/`.

## Resources

`resources` installs inert repository assets from package content:

- `source` is always package-root-relative.
- `destination` is always repository-root-relative.
- Allowed destination namespaces:
- `.github/ISSUE_TEMPLATE/*.{yml,yaml}` (direct children only; nested subdirectories such as `.github/ISSUE_TEMPLATE/bug_report/bug.yml` fail manifest validation)
- `.github/CODEOWNERS`
- `.github/aw/**`

`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`.

If `includes` is omitted or contains no valid workflow paths, `gh aw add` scans:

- `workflows/`
- `.github/workflows/`

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

Packages can still be valid without workflows when they declare installable `resources`, skills, or agents.

The embedded JSON schema source of truth lives in `pkg/parser/schemas/aw_manifest_schema.json`.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ sidebar:

# aw.yml Repository Package Manifest Specification

**Version**: 0.2.0
**Version**: 0.2.1
**Status**: Draft

## Abstract
Expand Down Expand Up @@ -49,6 +49,7 @@ The manifest document MUST be a YAML mapping. Unknown top-level fields MUST be r
| `license` | string | No | SPDX license identifier or license name for the package. |
| `files` | array of strings | No | Deprecated. Explicit installable workflow file list. Use `includes` instead. |
| `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. |
| `resources` | array of mappings | No | Declarative repository assets copied as-is to allowlisted destinations. |

### 4.2 `manifest-version`

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

`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.

### 4.10 `resources`

If present, `resources` MUST be an array of mappings. Each mapping MUST contain:

| Key | Type | Required | Meaning |
| --- | --- | --- | --- |
| `source` | string | Yes | Package-relative path of the asset to copy. |
| `destination` | string | Yes | Repository-root-relative destination path. |

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.

Resource destinations are restricted to non-hook repository asset namespaces:

- `.github/ISSUE_TEMPLATE/*.yml`
- `.github/ISSUE_TEMPLATE/*.yaml`
- `.github/CODEOWNERS`
- `.github/aw/**`

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.

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`.

## 5. Installable file resolution

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

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

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

### 5.1 Install

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

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

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

**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.

**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.
**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.

**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.

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

Implementations SHOULD emit warnings for at least the following conditions:

Expand Down
4 changes: 4 additions & 0 deletions package.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ emoji: 🤖
files:
- workflows/example.md
- .github/workflows/repo-workflow.md
resources:
- source: templates/bug.yml
destination: .github/ISSUE_TEMPLATE/bug.yml
```

Requirements:
Expand All @@ -58,6 +61,7 @@ Requirements:
- `description`: concise and relevant to the actual workflows
- `emoji`: optional package emoji (string)
- `files`: complete list of installable agentic/shared workflows in this repository
- `resources`: optional package-root-relative assets copied to allowlisted destinations such as `.github/ISSUE_TEMPLATE/*.yml`, `.github/CODEOWNERS`, or `.github/aw/**`
- File paths must be package-root-relative and point to existing markdown workflow files under `workflows/` or `.github/workflows/`

Do not invent custom package metadata fields.
Expand Down
56 changes: 56 additions & 0 deletions pkg/cli/add_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,15 @@ func addWorkflowsWithTracking(ctx context.Context, workflows []*ResolvedWorkflow
}
}

if err := writePackageOwnershipRecords(workflows, tracker, opts); err != nil {
if tracker != nil {
if rollbackErr := tracker.RollbackAllFiles(opts.Verbose); rollbackErr != nil {
return fmt.Errorf("failed to write package ownership records (rollback also failed): %w", errors.Join(err, rollbackErr))
}
}
return err
}

if !opts.Quiet && len(workflows) > 1 {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully added all %d workflows", len(workflows))))
}
Expand Down Expand Up @@ -406,6 +415,10 @@ func addWorkflowWithTracking(ctx context.Context, resolved *ResolvedWorkflow, tr
if resolved.IsPackageAgentFile {
return addAgentFileWithTracking(resolved, tracker, opts, gitRoot)
}
// Package resources are copied as-is to their declared repository-relative destinations.
if resolved.IsPackageResourceFile {
return addResourceFileWithTracking(resolved, tracker, opts, gitRoot)
}
sourceRepo := ""
if sourceInfo != nil && !sourceInfo.IsLocal {
sourceRepo = workflowSpec.RepoSlug
Expand Down Expand Up @@ -469,6 +482,49 @@ func validateWorkflowDestination(githubWorkflowsDir, workflowName, sourceRepo st
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)
}

func addResourceFileWithTracking(resolved *ResolvedWorkflow, tracker *FileTracker, opts AddOptions, gitRoot string) error {
destination := filepath.Clean(filepath.FromSlash(resolved.Spec.DestinationPath))
if destination == "." || filepath.IsAbs(destination) || strings.HasPrefix(destination, ".."+string(os.PathSeparator)) {
return fmt.Errorf("resource destination %q is invalid", resolved.Spec.DestinationPath)
}
destFile := filepath.Join(gitRoot, destination)
rel, err := filepath.Rel(gitRoot, destFile)
if err != nil {
return fmt.Errorf("failed to validate resource destination %q: %w", resolved.Spec.DestinationPath, err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return fmt.Errorf("resource destination %q escapes repository root", resolved.Spec.DestinationPath)
}

fileExists := fileutil.FileExists(destFile)
if fileExists && !opts.Force {
packageSource := packageSourceForSpec(resolved.Spec, resolved.SourceInfo)
if owned, drifted := packageOwnershipAllowsOverwrite(gitRoot, rel, packageSource); !owned || drifted {
Comment on lines +499 to +502
if owned {
return fmt.Errorf("resource %q has local modifications; use --force to overwrite", resolved.Spec.DestinationPath)
}
return fmt.Errorf("resource %q already exists; use --force to overwrite", resolved.Spec.DestinationPath)
}
}
if err := os.MkdirAll(filepath.Dir(destFile), constants.DirPermPublic); err != nil {
return fmt.Errorf("failed to create resource directory %s: %w", filepath.Dir(destFile), err)
}
if tracker != nil {
if fileExists {
tracker.TrackModified(destFile)
} else {
tracker.TrackCreated(destFile)
}
}
if err := os.WriteFile(destFile, resolved.Content, constants.FilePermPublic); err != nil {
return fmt.Errorf("failed to write resource file %q: %w", destFile, err)
}
if !opts.Quiet {
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Added resource: "+filepath.ToSlash(rel)))
}
return nil
}

func compileAddedWorkflow(ctx context.Context, destFile string, workflowSpec *WorkflowSpec, githubWorkflowsDir string, tracker *FileTracker, opts AddOptions) {
// For remote workflows: now that the main workflow and all its imports are on disk,
// parse the fully merged safe-outputs configuration to discover any dispatch or
Expand Down
Loading
Loading