-
Notifications
You must be signed in to change notification settings - Fork 499
Re-resolve package manifests during workflow updates #54119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7ee5193
06a68ac
935ee31
9a84882
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
| ) | ||
|
|
@@ -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)) | ||
| if _, err := os.Stat(destPath); err == nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 casefunc 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 💡 Consider accumulating errorsAccumulate failures and continue — similar to how 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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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")) | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 💡 SuggestionAdd content assertions after the 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 | ||
|
|
||
There was a problem hiding this comment.
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
.ymlbut GitHub Actions workflows are equally valid with a.yamlextension. A package providing action workflows named*.yamlwill be silently skipped during updates.@copilot please address this.