Skip to content
Merged
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
43 changes: 43 additions & 0 deletions docs/adr/54119-reconcile-manifest-managed-assets-on-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# ADR-54119: Reconcile Manifest-Managed Assets During `gh aw update`

**Date**: 2026-08-20
**Status**: Draft
**Deciders**: Unknown

---

### Context

`gh aw update` previously updated only already-installed Markdown workflow files that carry a `source:` frontmatter field. Package manifests (`aw.yml`) can also declare non-Markdown assets—action workflows (`.yml`), skills, and agents—under their `includes` list. Because these assets do not carry per-file source tracking, new entries added to a package manifest after initial installation were silently skipped, leaving installed packages incomplete even as their existing workflows advanced to the latest source commit.

### Decision

We will re-resolve both the current and latest package manifests during `gh aw update` and install any package-owned assets (action workflows, skills, agents) that appear in the latest manifest but are absent from the current one. Installation is conservative: if the destination path already exists on disk the file is skipped to avoid overwriting local modifications. Ownership is derived from the manifest rather than per-file frontmatter.

### Alternatives Considered

#### Alternative 1: Require `source:` Frontmatter on All Package-Managed Assets

Package authors could be required to embed `source:` metadata into every asset file. This would let the existing per-file update logic handle all asset types uniformly without new manifest-diffing code. However, it couples the manifest format to each asset's content, imposes a documentation burden on package authors, and is impractical for binary or generated files that cannot carry YAML frontmatter.

#### Alternative 2: Track Installed Manifest Assets in a Dedicated Lockfile

A separate lockfile (e.g., `aw.lock`) could record which asset paths were installed from which package version, enabling precise reconciliation and future removal support. This would centralize ownership tracking and decouple it from both frontmatter and manifest diffing. The trade-off is added file-system overhead, a required migration step for existing installations that lack a lockfile, and increased complexity in the update flow.

### Consequences

#### Positive
- New assets declared in `aw.yml` are automatically installed when users run `gh aw update`, delivering complete package updates without manual intervention.
- The conservative install behavior (destination-exists check) prevents unintentional overwrites of locally modified files.

#### Negative
- Assets are keyed by destination path (action workflows) or source path (skills/agents). A package that renames an asset across versions will produce a duplicate installation rather than an in-place update, requiring manual cleanup.
- Reconciliation errors are appended to the failure list for the entire workflow group, so a single failed asset download can suppress success reporting for other updates in the same run.

#### Neutral
- The reconciliation logic runs unconditionally at the end of every `updateManifestWorkflowGroup` call, adding a GitHub API download per newly declared asset; no additional network calls are made for unchanged assets.
- Removal of assets dropped from the manifest is not addressed by this change; removal behavior remains conservative (unchanged from the prior implementation).

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
125 changes: 125 additions & 0 deletions pkg/cli/update_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/workflow"
)
Expand Down Expand Up @@ -192,9 +193,133 @@ func updateManifestWorkflowGroup(ctx context.Context, source string, grouped []*
successes = append(successes, name)
}

assetEngine := resolveManifestAssetEngine(grouped, opts)
if err := reconcileManifestManagedAssets(ctx, repoSpec.RepoSlug, currentPkg, latestPkg, assetEngine); err != nil {
failures = append(failures, updateFailure{Name: source, Error: err.Error()})
}

return successes, failures
}

// reconcileManifestManagedAssets installs package-owned action workflows, skills, and
// agents that were added to the latest manifest. These assets do not carry source
// frontmatter, so their package ownership is derived from the package manifest itself.
func reconcileManifestManagedAssets(ctx context.Context, repo string, _ *resolvedRepositoryPackage, latestPkg *resolvedRepositoryPackage, engineOverride string) error {
gitRoot, err := gitutil.FindGitRoot()
if err != nil {
return fmt.Errorf("failed to find repository root for package assets: %w", err)
}
owner, repository, err := splitRepositoryPackageSlug(repo)
if err != nil {
return err
}

for _, installable := range latestPkg.InstallationSource {
if !isActionWorkflowPath(installable.SourcePath) {
continue
}
destPath := filepath.Join(gitRoot, filepath.FromSlash(installable.DestinationPath))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The suffix check only accepts .yml but GitHub Actions workflows are equally valid with a .yaml extension. A package providing action workflows named *.yaml will be silently skipped during updates.

// Before
!strings.HasSuffix(strings.ToLower(installable.SourcePath), ".yml")

// After
ext := strings.ToLower(filepath.Ext(installable.SourcePath))
ext != ".yml" && ext != ".yaml"

@copilot please address this.

if _, err := os.Stat(destPath); err == nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] There's no test for the case where a destination file already exists — the skip-if-exists logic (line 229–231) has zero test coverage, so a future regression that accidentally overwrites existing files would go undetected.

💡 Suggested test case
func TestReconcileManifestManagedAssets_SkipsExistingDestination(t *testing.T) {
    tmpDir := testutil.TempDir(t, "manifest-assets-skip-*")
    require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755))
    require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, ".github", "workflows"), 0o755))
    existing := filepath.Join(tmpDir, ".github", "workflows", "existing.yml")
    require.NoError(t, os.WriteFile(existing, []byte("original"), 0o644))
    t.Chdir(tmpDir)

    downloadCalled := false
    downloadPackageFileFromGitHubForHost = func(...) ([]byte, error) {
        downloadCalled = true
        return []byte("overwrite"), nil
    }

    err := reconcileManifestManagedAssets(context.Background(), "owner/repo",
        &resolvedRepositoryPackage{},
        &resolvedRepositoryPackage{
            ResolvedRef: "v2",
            InstallationSource: []resolvedPackageInstallable{{
                SourcePath: ".github/workflows/existing.yml",
                DestinationPath: ".github/workflows/existing.yml",
            }},
        },
        UpdateWorkflowsOptions{},
    )
    require.NoError(t, err)
    assert.False(t, downloadCalled, "should not download when destination already exists")
    content, _ := os.ReadFile(existing)
    assert.Equal(t, "original", string(content), "existing file should not be overwritten")
}

@copilot please address this.

updateManifestLog.Printf("Skipping new package action workflow because destination already exists: %s", destPath)
continue
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to inspect new package action workflow destination %s: %w", destPath, err)
}
content, err := downloadPackageFileFromGitHubForHost(ctx, owner, repository, installable.SourcePath, latestPkg.ResolvedRef, "")
if err != nil {
return fmt.Errorf("failed to download new package action workflow %s: %w", installable.SourcePath, err)
}
if err := os.MkdirAll(filepath.Dir(destPath), constants.DirPermPublic); err != nil {
return fmt.Errorf("failed to create package action workflow directory: %w", err)
}
if err := os.WriteFile(destPath, content, constants.FilePermPublic); err != nil {
return fmt.Errorf("failed to install new package action workflow %s: %w", installable.DestinationPath, err)
}
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Added package action workflow: "+filepath.Base(destPath)))
}

for _, skill := range latestPkg.SkillFiles {
destPath, err := packageSkillDestinationPath(gitRoot, skill, engineOverride)
if err != nil {
return err
}
if _, err := os.Stat(destPath); err == nil {
updateManifestLog.Printf("Skipping new package skill because destination already exists: %s", destPath)
continue
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to inspect new package skill destination %s: %w", destPath, err)
}
content, err := downloadPackageFileFromGitHubForHost(ctx, owner, repository, skill.SourcePath, latestPkg.ResolvedRef, "")
if err != nil {
return fmt.Errorf("failed to download new package skill %s: %w", skill.SourcePath, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnosing-bugs] A single download error in the skills or agents loops causes the entire reconcile to fail (via return err), aborting installation of all subsequent assets. This is an early-exit-on-first-failure pattern that could leave the repository in a partially installed state with no indication of which assets were installed vs skipped.

💡 Consider accumulating errors

Accumulate failures and continue — similar to how updateManifestWorkflowGroup collects failures []updateFailure rather than returning on the first error:

var errs []error
for _, skill := range latestPkg.SkillFiles {
    if err := installSkill(...); err != nil {
        errs = append(errs, err)
    }
}
return errors.Join(errs...)

This ensures all installable assets are attempted and the caller gets a complete picture.

@copilot please address this.

}
resolved := &ResolvedWorkflow{
Content: content,
Spec: &WorkflowSpec{WorkflowPath: skill.SourcePath},
IsPackageSkillFile: true,
SkillName: skill.SkillName,
}
if err := addSkillFileWithTracking(resolved, nil, AddOptions{
EngineOverride: engineOverride,
Quiet: false,
}, gitRoot); err != nil {
return fmt.Errorf("failed to install new package skill %s: %w", skill.SourcePath, err)
}
}

for _, agent := range latestPkg.AgentFiles {
destPath := packageAgentDestinationPath(gitRoot, agent, engineOverride)
if _, err := os.Stat(destPath); err == nil {
updateManifestLog.Printf("Skipping new package agent because destination already exists: %s", destPath)
continue
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to inspect new package agent destination %s: %w", destPath, err)
}
content, err := downloadPackageFileFromGitHubForHost(ctx, owner, repository, agent, latestPkg.ResolvedRef, "")
if err != nil {
return fmt.Errorf("failed to download new package agent %s: %w", agent, err)
}
resolved := &ResolvedWorkflow{Content: content, Spec: &WorkflowSpec{WorkflowPath: agent}, IsPackageAgentFile: true}
if err := addAgentFileWithTracking(resolved, nil, AddOptions{EngineOverride: engineOverride}, gitRoot); err != nil {
return fmt.Errorf("failed to install new package agent %s: %w", agent, err)
}
}
return nil
}

func resolveManifestAssetEngine(grouped []*workflowWithSource, opts UpdateWorkflowsOptions) string {
if opts.EngineOverride != "" {
return opts.EngineOverride
}
for _, wf := range grouped {
content, err := os.ReadFile(wf.Path)
if err != nil {
continue
}
if engine := strings.TrimSpace(ExtractWorkflowEngine(string(content))); engine != "" {
updateManifestLog.Printf("Using engine %q from installed manifest-managed workflow %s for package asset reconciliation", engine, wf.Name)
return engine
}
}
return ""
}

func packageSkillDestinationPath(gitRoot string, skill resolvedPackageSkillFile, engineOverride string) (string, error) {
resolved := &ResolvedWorkflow{
Spec: &WorkflowSpec{WorkflowPath: skill.SourcePath},
SkillName: skill.SkillName,
}
relPath, err := resolveSkillRelativePath(resolved)
if err != nil {
return "", fmt.Errorf("failed to resolve destination for package skill %s: %w", skill.SourcePath, err)
}
return filepath.Join(gitRoot, workflow.GetEngineSkillDir(engineOverride), skill.SkillName, relPath), nil
}

func packageAgentDestinationPath(gitRoot, sourcePath, engineOverride string) string {
return filepath.Join(gitRoot, workflow.GetEngineSubAgentDir(engineOverride), filepath.Base(sourcePath))
}

func removeManifestManagedWorkflow(workflowPath string) error {
updateManifestLog.Printf("Removing manifest-managed workflow no longer in manifest: %s", filepath.Base(workflowPath))
if err := os.Remove(workflowPath); err != nil && !os.IsNotExist(err) {
Expand Down
107 changes: 107 additions & 0 deletions pkg/cli/update_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,115 @@ import (
"time"

"github.com/github/gh-aw/pkg/testutil"
"github.com/github/gh-aw/pkg/workflow"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestReconcileManifestManagedAssets_AddsPackageOwnedAssets(t *testing.T) {
tmpDir := testutil.TempDir(t, "manifest-assets-*")
require.NoError(t, os.Mkdir(filepath.Join(tmpDir, ".git"), 0o755))
t.Chdir(tmpDir)

originalDownload := downloadPackageFileFromGitHubForHost
t.Cleanup(func() { downloadPackageFileFromGitHubForHost = originalDownload })
downloadPackageFileFromGitHubForHost = func(_ context.Context, owner, repo, path, ref, host string) ([]byte, error) {
if owner != "owner" || repo != "repo" || ref != "v2.0.0" {
return nil, fmt.Errorf("unexpected package source %s/%s@%s", owner, repo, ref)
}
switch path {
case ".github/workflows/new.yml":
return []byte("name: new action\n"), nil
case "skills/review/scripts/check.sh":
return []byte("#!/bin/sh\n"), nil
case "agents/reviewer.md":
return []byte("# Reviewer\n"), nil
default:
return nil, fmt.Errorf("unexpected package path %s", path)
}
}

err := reconcileManifestManagedAssets(context.Background(), "owner/repo",
&resolvedRepositoryPackage{},
&resolvedRepositoryPackage{
ResolvedRef: "v2.0.0",
InstallationSource: []resolvedPackageInstallable{{
SourcePath: ".github/workflows/new.yml",
DestinationPath: ".github/workflows/new.yml",
}},
SkillFiles: []resolvedPackageSkillFile{{
SourcePath: "skills/review/scripts/check.sh",
SkillName: "review",
}},
AgentFiles: []string{"agents/reviewer.md"},
},
"copilot",
)
require.NoError(t, err)
workflowPath := filepath.Join(tmpDir, ".github", "workflows", "new.yml")
assert.FileExists(t, workflowPath)
assert.FileExists(t, filepath.Join(tmpDir, workflow.GetEngineSkillDir("copilot"), "review", "scripts", "check.sh"))
assert.FileExists(t, filepath.Join(tmpDir, workflow.GetEngineSubAgentDir("copilot"), "reviewer.md"))
workflowContent, readErr := os.ReadFile(workflowPath)
require.NoError(t, readErr)
assert.Equal(t, "name: new action\n", string(workflowContent))
}

func TestReconcileManifestManagedAssets_BranchTrackingInstallsMissingAssets(t *testing.T) {
tmpDir := testutil.TempDir(t, "manifest-assets-branch-main-*")
require.NoError(t, os.Mkdir(filepath.Join(tmpDir, ".git"), 0o755))
t.Chdir(tmpDir)

originalDownload := downloadPackageFileFromGitHubForHost
t.Cleanup(func() { downloadPackageFileFromGitHubForHost = originalDownload })
downloadPackageFileFromGitHubForHost = func(_ context.Context, owner, repo, path, ref, host string) ([]byte, error) {
if owner != "owner" || repo != "repo" || ref != "7d8e9f0" {
return nil, fmt.Errorf("unexpected package source %s/%s@%s", owner, repo, ref)
}
switch path {
case ".github/workflows/new.yml":
return []byte("name: branch action\n"), nil
case "skills/review/scripts/check.sh":
return []byte("#!/bin/sh\n"), nil
case "agents/reviewer.md":
return []byte("# Reviewer\n"), nil
default:
return nil, fmt.Errorf("unexpected package path %s", path)
}
}

currentAndLatest := &resolvedRepositoryPackage{
ResolvedRef: "7d8e9f0",
InstallationSource: []resolvedPackageInstallable{{
SourcePath: ".github/workflows/new.yml",
DestinationPath: ".github/workflows/new.yml",
}},
SkillFiles: []resolvedPackageSkillFile{{
SourcePath: "skills/review/scripts/check.sh",
SkillName: "review",
}},
AgentFiles: []string{"agents/reviewer.md"},
}
err := reconcileManifestManagedAssets(context.Background(), "owner/repo", currentAndLatest, currentAndLatest, "copilot")
require.NoError(t, err)

assert.FileExists(t, filepath.Join(tmpDir, ".github", "workflows", "new.yml"))
assert.FileExists(t, filepath.Join(tmpDir, workflow.GetEngineSkillDir("copilot"), "review", "scripts", "check.sh"))
assert.FileExists(t, filepath.Join(tmpDir, workflow.GetEngineSubAgentDir("copilot"), "reviewer.md"))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The test only asserts that files exist but doesn't verify their content matches what the mock download returned. A content check would catch regressions where the write path silently truncates or transforms the file.

💡 Suggestion

Add content assertions after the FileExists checks:

content, err := os.ReadFile(filepath.Join(tmpDir, ".github", "workflows", "new.yml"))
require.NoError(t, err)
assert.Equal(t, "name: new action\n", string(content))

@copilot please address this.

func TestResolveManifestAssetEngine(t *testing.T) {
tmpDir := testutil.TempDir(t, "manifest-assets-engine-*")
workflowPath := filepath.Join(tmpDir, "existing.md")
require.NoError(t, os.WriteFile(workflowPath, []byte("---\nengine: claude\nsource: owner/repo@main\n---\n\n# Existing\n"), 0o644))

engine := resolveManifestAssetEngine([]*workflowWithSource{{Name: "existing", Path: workflowPath}}, UpdateWorkflowsOptions{})
assert.Equal(t, "claude", engine)

overrideEngine := resolveManifestAssetEngine([]*workflowWithSource{{Name: "existing", Path: workflowPath}}, UpdateWorkflowsOptions{EngineOverride: "copilot"})
assert.Equal(t, "copilot", overrideEngine)
}

func TestUpdateManifestWorkflowGroup_AddsUpdatesRemoves(t *testing.T) {
originalResolveLatestRef := resolveLatestRefFn
originalDownloadPackage := downloadPackageFileFromGitHubForHost
Expand Down
Loading