Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions .changeset/minor-add-project-file-support.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pkg/cli/add_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ The -n flag allows you to specify a custom name for the workflow file (not allow
The --dir flag allows you to specify the workflow directory (default: .github/workflows).
The --create-pull-request flag creates a pull request with the workflow changes.
The --force flag overwrites existing workflow files.
When a package contains .github/workflows/aw.json, its project settings are
merged into the target repository and the added package settings take precedence.

Note: In GitHub Enterprise repos, shorthand source specs resolve on your enterprise host by default.
For github/*, githubnext/*, and microsoft/* sources, shorthand resolves on github.com.
Expand Down
3 changes: 3 additions & 0 deletions pkg/cli/add_command_resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ func addNonWorkflowResourceWithTracking(resolved *ResolvedWorkflow, tracker *Fil
}
// Package resources are copied as-is to their declared repository-relative destinations.
if resolved.IsPackageResourceFile {
if resolved.IsPackageProjectFile {
return true, mergeProjectFileWithTracking(resolved, tracker, gitRoot)

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.

mergeProjectFileWithTracking unconditionally merges into an existing aw.json, unlike every sibling resource writer in this file (addResourceFileWithTracking, addActionWorkflowWithTracking, addSkillFileWithTracking, addAgentFileWithTracking), which all gate overwrites behind if fileExists && !opts.Force { ... } and emit verbose/quiet messages. Here, opts isn't even passed to mergeProjectFileWithTracking (its signature is func mergeProjectFileWithTracking(resolved *ResolvedWorkflow, tracker *FileTracker, gitRoot string) error), so there's no way to respect --force, and no console output at all when an existing project file is modified.

Practically this means running gh aw add owner/repo against a package that ships an aw.json will silently rewrite the user's local .github/workflows/aw.json settings even without --force, with zero indication that anything changed. That's inconsistent with the rest of the add command's overwrite-protection model and could surprise users who expect --force to gate all destination file writes.

Recommend: thread opts AddOptions through to mergeProjectFileWithTracking, and either (a) skip/error on conflicting existing settings unless opts.Force is set (matching sibling behavior), or if merging without --force is intentional, at least emit a verbose/quiet message so the user knows aw.json was modified.

@copilot please address this.

}
return true, addResourceFileWithTracking(resolved, tracker, opts, gitRoot)
}
return false, nil
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/add_package_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type resolvedRepositoryPackage struct {
DocsPath string
InstallationSource []resolvedPackageInstallable
ResourceFiles []resolvedPackageResource
ProjectFile *resolvedPackageResource
Bootstrap *repositoryPackageBootstrap
SkillFiles []resolvedPackageSkillFile
AgentFiles []string
Expand Down
27 changes: 24 additions & 3 deletions pkg/cli/add_package_manifest_resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/workflow"
)

type resolvedRepositoryPackageAssets struct {
Expand Down Expand Up @@ -50,6 +51,10 @@ func resolveRepositoryPackage(ctx context.Context, repoSpec *RepoSpec, host stri
return nil, err
}
warnings = append(warnings, assets.warnings...)
projectFile, err := resolveRepositoryPackageProjectFile(ctx, owner, repo, packagePath, ref, host)
if err != nil {
return nil, err
}
if err := validateUniqueResolvedPackageFiles(assets.installationSources, assets.resourceFiles, assets.extensionFiles.skillFiles, assets.extensionFiles.agentFiles, manifestPath); err != nil {
return nil, err
}
Expand All @@ -61,10 +66,26 @@ func resolveRepositoryPackage(ctx context.Context, repoSpec *RepoSpec, host stri
if err != nil {
return nil, err
}
if len(assets.installationSources) == 0 && len(assets.resourceFiles) == 0 && len(assets.extensionFiles.skillFiles) == 0 && len(assets.extensionFiles.agentFiles) == 0 {
return nil, fmt.Errorf("repository %q does not contain any installable workflows, resources, skills, or agents (either explicitly declared or auto-discovered). Add workflows under 'workflows/', resources in aw.yml, skills under 'skills/', or agents under 'agents/', or declare them explicitly in aw.yml", repositoryPackageIdentifier(repoSpec.RepoSlug, packagePath))
if len(assets.installationSources) == 0 && len(assets.resourceFiles) == 0 && len(assets.extensionFiles.skillFiles) == 0 && len(assets.extensionFiles.agentFiles) == 0 && projectFile == nil {
return nil, fmt.Errorf("repository %q does not contain any installable workflows, resources, skills, agents, or aw.json project settings (either explicitly declared or auto-discovered). Add workflows under 'workflows/', resources in aw.yml, skills under 'skills/', agents under 'agents/', or an aw.json file", repositoryPackageIdentifier(repoSpec.RepoSlug, packagePath))
}
return newResolvedRepositoryPackage(manifestPath, ref, docsPath, manifest, assets.installationSources, assets.resourceFiles, assets.extensionFiles, warnings), nil
pkg := newResolvedRepositoryPackage(manifestPath, ref, docsPath, manifest, assets.installationSources, assets.resourceFiles, assets.extensionFiles, warnings)
pkg.ProjectFile = projectFile
return pkg, nil
}

func resolveRepositoryPackageProjectFile(ctx context.Context, owner, repo, packagePath, ref, host string) (*resolvedPackageResource, error) {
sourcePath := joinRepositoryPackagePath(packagePath, workflow.RepoConfigFileName)
if _, err := downloadPackageFileFromGitHubForHost(ctx, owner, repo, sourcePath, ref, host); err != nil {
if isRepositoryFileNotFound(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read package project file %q: %w", sourcePath, err)
}
return &resolvedPackageResource{
SourcePath: sourcePath,
DestinationPath: workflow.RepoConfigFileName,
}, nil
}

func resolveRepositoryPackageManifestNodes(ctx context.Context, owner, repo, ref, host, repoSlug string, nodes []repositoryPackageManifestNode) (*resolvedRepositoryPackageAssets, error) {
Expand Down
33 changes: 33 additions & 0 deletions pkg/cli/add_package_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"testing"

"github.com/github/gh-aw/pkg/workflow"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -1016,6 +1017,38 @@ func TestResolveLocalRepositoryPackagePreservesIcon(t *testing.T) {
assert.Equal(t, "📦", pkg.Icon)
}

func TestResolveLocalRepositoryPackageDiscoversProjectFile(t *testing.T) {
packageDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(packageDir, "aw.yml"), []byte("name: Local Package\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(packageDir, "README.md"), []byte("# Local Package\n"), 0o644))
projectFile := filepath.Join(packageDir, filepath.FromSlash(workflow.RepoConfigFileName))
require.NoError(t, os.MkdirAll(filepath.Dir(projectFile), 0o755))
require.NoError(t, os.WriteFile(projectFile, []byte(`{"utc":"+01:00"}`), 0o644))

pkg, err := resolveLocalRepositoryPackage(packageDir)
require.NoError(t, err)
require.NotNil(t, pkg.ProjectFile)
assert.Equal(t, projectFile, pkg.ProjectFile.SourcePath)
assert.Equal(t, workflow.RepoConfigFileName, pkg.ProjectFile.DestinationPath)
}

func TestResolveRepositoryPackageProjectFile(t *testing.T) {
originalDownload := downloadPackageFileFromGitHubForHost
t.Cleanup(func() {
downloadPackageFileFromGitHubForHost = originalDownload
})
downloadPackageFileFromGitHubForHost = func(_ context.Context, owner, repo, filePath, ref, host string) ([]byte, error) {
assert.Equal(t, "packages/repo-assist/.github/workflows/aw.json", filePath)
return []byte(`{"utc":"+01:00"}`), nil
}

projectFile, err := resolveRepositoryPackageProjectFile(t.Context(), "owner", "repo", "packages/repo-assist", "main", "github.com")
require.NoError(t, err)
require.NotNil(t, projectFile)
assert.Equal(t, "packages/repo-assist/.github/workflows/aw.json", projectFile.SourcePath)
assert.Equal(t, workflow.RepoConfigFileName, projectFile.DestinationPath)
}

func TestResolveWorkflows_RepositoryPackage(t *testing.T) {
originalFetchFn := fetchWorkflowFromSourceWithContextFn
originalDownload := downloadPackageFileFromGitHubForHost
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/add_package_ownership.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func writePackageOwnershipRecords(workflows []*ResolvedWorkflow, tracker *FileTr
func packageManagedWorkflowGroups(workflows []*ResolvedWorkflow) map[string][]*ResolvedWorkflow {
groups := make(map[string][]*ResolvedWorkflow)
for _, resolved := range workflows {
if resolved == nil || resolved.Spec == nil || !resolved.Spec.FromRepositoryManifest {
if resolved == nil || resolved.Spec == nil || !resolved.Spec.FromRepositoryManifest || resolved.IsPackageProjectFile {
continue
}
source := packageSourceForSpec(resolved.Spec, resolved.SourceInfo)
Expand Down
98 changes: 98 additions & 0 deletions pkg/cli/add_project_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cli

import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"

"github.com/github/gh-aw/pkg/constants"
"github.com/github/gh-aw/pkg/fileutil"
"github.com/github/gh-aw/pkg/workflow"
)

func mergeProjectFileWithTracking(resolved *ResolvedWorkflow, tracker *FileTracker, gitRoot string) error {
destFile := filepath.Join(gitRoot, workflow.RepoConfigFileName)
existing := []byte("{}")
fileExists := fileutil.FileExists(destFile)

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.

Merging a package aw.json blindly overwrites any existing repository-owned value in .github/workflows/aw.json without the same ownership/--force guard the rest of gh aw add applies to package resources. That means adding a package can silently rewrite project-level settings like maintenance.runs_on or action_pins in a repo that never opted into that package owning the file.

💡 Why this should block merge

addResourceFileWithTracking rejects overwriting an existing destination unless the file is already owned by the same package or --force was passed, but mergeProjectFileWithTracking skips that check entirely. Because aw.json is shared repo configuration rather than a package-owned asset, this path will mutate existing settings in place on every install, even when the repository already has hand-maintained config.

A safer approach is to apply the same overwrite gate before merging, or at minimum fail when .github/workflows/aw.json already exists and the caller did not pass --force.

if fileExists {
var err error
existing, err = os.ReadFile(filepath.Clean(destFile))
if err != nil {
return fmt.Errorf("failed to read project file %q: %w", workflow.RepoConfigFileName, err)
}
}

merged, err := mergeProjectJSON(existing, resolved.Content)
if err != nil {
return fmt.Errorf("failed to merge package project file %q: %w", resolved.Spec.WorkflowPath, err)
}
if err := validateMergedProjectJSON(merged); err != nil {
return fmt.Errorf("added package project settings are invalid: %w", err)
}
if err := os.MkdirAll(filepath.Dir(destFile), constants.DirPermPublic); err != nil {
return fmt.Errorf("failed to create project file directory: %w", err)
}

if tracker != nil {
if fileExists {
tracker.TrackModified(destFile)
} else {
tracker.TrackCreated(destFile)
}
}
Comment on lines +47 to +60
if err := os.WriteFile(destFile, merged, constants.FilePermPublic); err != nil {
return fmt.Errorf("failed to write project file %q: %w", workflow.RepoConfigFileName, err)
}
return nil
}

func validateMergedProjectJSON(merged []byte) error {

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.

pkg/cli/add_project_file.go:51-66: shrink: temp-directory round-trip only to validate merged aw.json. Validate the merged JSON in memory and drop the temp-file scaffolding.

tempRoot, err := os.MkdirTemp("", "gh-aw-project-config-*")
if err != nil {
return fmt.Errorf("failed to create temporary project config directory: %w", err)
}
defer os.RemoveAll(tempRoot)
configPath := filepath.Join(tempRoot, workflow.RepoConfigFileName)
if err := os.MkdirAll(filepath.Dir(configPath), constants.DirPermPublic); err != nil {
return fmt.Errorf("failed to create temporary project config directory: %w", err)
}
if err := os.WriteFile(configPath, merged, constants.FilePermPublic); err != nil {
return fmt.Errorf("failed to write temporary project config: %w", err)
}
_, err = workflow.LoadRepoConfig(tempRoot)
return err
}

func mergeProjectJSON(existing, added []byte) ([]byte, error) {
var existingSettings map[string]any
if err := json.Unmarshal(existing, &existingSettings); err != nil {
return nil, fmt.Errorf("target project file is not valid JSON: %w", err)
}
var addedSettings map[string]any
if err := json.Unmarshal(added, &addedSettings); err != nil {
return nil, fmt.Errorf("added project file is not valid JSON: %w", err)
}
if existingSettings == nil || addedSettings == nil {
return nil, errors.New("project files must contain JSON objects")
}
mergeProjectSettings(existingSettings, addedSettings)
merged, err := json.MarshalIndent(existingSettings, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to encode merged project file: %w", err)
}
return append(merged, '\n'), nil
}

func mergeProjectSettings(target, added map[string]any) {
for key, addedValue := range added {
addedObject, addedIsObject := addedValue.(map[string]any)
targetObject, targetIsObject := target[key].(map[string]any)
if addedIsObject && targetIsObject {
mergeProjectSettings(targetObject, addedObject)
continue
}
target[key] = addedValue
}
}
98 changes: 98 additions & 0 deletions pkg/cli/add_project_file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cli

import (
"encoding/json"
"os"
"path/filepath"
"testing"

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

func TestMergeProjectJSONAddedSettingsWin(t *testing.T) {
existing := []byte(`{
"utc": "-08:00",
"help_command": true,
"maintenance": {
"runs_on": "self-hosted",
"label_triggers": false
},
"action_pins": {
"actions/checkout@v4": "internal/checkout@v4"
}
}`)
added := []byte(`{
"utc": "+01:00",
"maintenance": {
"label_triggers": true
},
"action_pins": {
"actions/setup-go@v5": "internal/setup-go@v5"
}
}`)

merged, err := mergeProjectJSON(existing, added)
require.NoError(t, err)

var settings map[string]any
require.NoError(t, json.Unmarshal(merged, &settings))
assert.Equal(t, "+01:00", settings["utc"])
assert.Equal(t, true, settings["help_command"])
assert.Equal(t, map[string]any{
"runs_on": "self-hosted",
"label_triggers": true,
}, settings["maintenance"])
assert.Equal(t, map[string]any{
"actions/checkout@v4": "internal/checkout@v4",
"actions/setup-go@v5": "internal/setup-go@v5",
}, settings["action_pins"])
}

func TestMergeProjectJSONRequiresObjects(t *testing.T) {
_, err := mergeProjectJSON([]byte(`{}`), []byte(`[]`))
require.ErrorContains(t, err, "added project file is not valid JSON")
}

func TestMergeProjectFileWithTracking(t *testing.T) {
gitRoot := t.TempDir()
destFile := filepath.Join(gitRoot, workflow.RepoConfigFileName)
require.NoError(t, os.MkdirAll(filepath.Dir(destFile), 0o755))
require.NoError(t, os.WriteFile(destFile, []byte("{\"utc\":\"-08:00\",\"help_command\":true}\n"), 0o644))

resolved := &ResolvedWorkflow{
Spec: &WorkflowSpec{
WorkflowPath: "aw.json",
DestinationPath: workflow.RepoConfigFileName,
IsPackageResourceFile: true,
},
Content: []byte(`{"utc":"+01:00"}`),
IsPackageProjectFile: true,
}
tracker := NewFileTracker()

require.NoError(t, mergeProjectFileWithTracking(resolved, tracker, gitRoot))
merged, err := os.ReadFile(destFile)
require.NoError(t, err)
assert.JSONEq(t, `{"utc":"+01:00","help_command":true}`, string(merged))
}

func TestMergeProjectFileRejectsInvalidSettingsWithoutWriting(t *testing.T) {
gitRoot := t.TempDir()
destFile := filepath.Join(gitRoot, workflow.RepoConfigFileName)
require.NoError(t, os.MkdirAll(filepath.Dir(destFile), 0o755))
original := []byte("{\"utc\":\"-08:00\"}\n")
require.NoError(t, os.WriteFile(destFile, original, 0o644))

resolved := &ResolvedWorkflow{
Spec: &WorkflowSpec{WorkflowPath: "aw.json"},
Content: []byte(`{"utc":"invalid"}`),
}
err := mergeProjectFileWithTracking(resolved, NewFileTracker(), gitRoot)
require.Error(t, err)

actual, readErr := os.ReadFile(destFile)
require.NoError(t, readErr)
assert.Equal(t, original, actual)
}
Loading
Loading