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
1 change: 1 addition & 0 deletions docs/src/content/docs/agent-factory-status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ These are experimental agentic workflows used by the GitHub Next team to learn,
| [Smoke Crush](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-crush.md) | crush | [![Smoke Crush](https://github.com/github/gh-aw/actions/workflows/smoke-crush.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-crush.lock.yml) | - | - |
| [Smoke Gemini](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-gemini.md) | gemini | [![Smoke Gemini](https://github.com/github/gh-aw/actions/workflows/smoke-gemini.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-gemini.lock.yml) | - | - |
| [Smoke Multi PR](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-multi-pr.md) | copilot | [![Smoke Multi PR](https://github.com/github/gh-aw/actions/workflows/smoke-multi-pr.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-multi-pr.lock.yml) | - | - |
| [Smoke OpenCode](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-opencode.md) | opencode | [![Smoke OpenCode](https://github.com/github/gh-aw/actions/workflows/smoke-opencode.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-opencode.lock.yml) | - | - |
| [Smoke Project](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-project.md) | copilot | [![Smoke Project](https://github.com/github/gh-aw/actions/workflows/smoke-project.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-project.lock.yml) | - | - |
| [Smoke Service Ports](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-service-ports.md) | copilot | [![Smoke Service Ports](https://github.com/github/gh-aw/actions/workflows/smoke-service-ports.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-service-ports.lock.yml) | - | - |
| [Smoke Temporary ID](https://github.com/github/gh-aw/blob/main/.github/workflows/smoke-temporary-id.md) | copilot | [![Smoke Temporary ID](https://github.com/github/gh-aw/actions/workflows/smoke-temporary-id.lock.yml/badge.svg)](https://github.com/github/gh-aw/actions/workflows/smoke-temporary-id.lock.yml) | - | - |
Expand Down
21 changes: 16 additions & 5 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,10 @@ metadata:

# Workflow specifications to import. Supports array form (list of paths) or object
# form with 'aw' (agentic workflow paths) subfield. Path resolution: (1) relative
# paths (e.g., 'shared/file.md') are
# resolved relative to the workflow's directory; (2) paths starting with
# '.github/' or '/' are resolved from the repository root (repo-root-relative);
# (3) paths matching 'owner/repo/path@ref' are fetched from GitHub at compile time
# (cross-repo).
# paths (e.g., 'shared/file.md') are resolved relative to the workflow's
# directory; (2) paths starting with '.github/' or '/' are resolved from the
# repository root (repo-root-relative); (3) paths matching 'owner/repo/path@ref'
# are fetched from GitHub at compile time (cross-repo).
# (optional)
# This field supports multiple formats (oneOf):

Expand Down Expand Up @@ -1567,6 +1566,12 @@ engine:
# (optional)
command: "example-value"

# Custom Node.js driver script filename for an agentic engine. This replaces the
# engine's built-in driver wrapper (when the engine supports one) and must end
# with .js, .cjs, or .mjs.
# (optional)
driver: "example-value"

# Custom environment variables to pass to the AI engine, including secret
# overrides (e.g., OPENAI_API_KEY: ${{ secrets.CUSTOM_KEY }})
# (optional)
Expand Down Expand Up @@ -5549,6 +5554,12 @@ safe-outputs:
# (optional)
concurrency-group: "example-value"

# Explicit additional custom workflow jobs that the consolidated safe_outputs job
# should depend on.
# (optional)
needs: []
# Array of strings

# Override the GitHub deployment environment for the safe-outputs job. When set,
# this environment is used instead of the top-level environment: field. When not
# set, the top-level environment: field is propagated automatically so that
Expand Down
5 changes: 0 additions & 5 deletions pkg/actionpins/actionpins.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,6 @@ var (
actionPinsOnce sync.Once
)

Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

Removing the exported GetActionPins function is a breaking change for any external consumers of pkg/actionpins, and the package docs/spec currently list GetActionPins as part of the public API. Consider keeping GetActionPins as a thin wrapper around getActionPins() (or deprecating it first) and updating the public API documentation/spec accordingly if you intend to remove it.

Suggested change
// GetActionPins returns the cached set of action pins loaded from the embedded JSON.
// It is kept as part of the package's public API for external consumers.
func GetActionPins() []ActionPin {
return getActionPins()
}

Copilot uses AI. Check for mistakes.
// GetActionPins returns all loaded action pins sorted by version descending.
func GetActionPins() []ActionPin {
return getActionPins()
}

func getActionPins() []ActionPin {
actionPinsOnce.Do(func() {
log.Print("Unmarshaling action pins from embedded JSON (first call, will be cached)")
Expand Down
25 changes: 3 additions & 22 deletions pkg/actionpins/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,6 @@ func TestSpec_PublicAPI_ExtractVersion(t *testing.T) {
}
}

// TestSpec_PublicAPI_GetActionPins validates that GetActionPins returns a non-nil slice.
func TestSpec_PublicAPI_GetActionPins(t *testing.T) {
pins := actionpins.GetActionPins()
assert.NotNil(t, pins, "GetActionPins should return non-nil slice of all loaded pins")
}

// TestSpec_PublicAPI_GetActionPinsByRepo validates GetActionPinsByRepo for known and unknown repos.
func TestSpec_PublicAPI_GetActionPinsByRepo(t *testing.T) {
t.Run("returns no pins for unknown repository", func(t *testing.T) {
Expand All @@ -144,11 +138,7 @@ func TestSpec_PublicAPI_GetActionPinsByRepo(t *testing.T) {
})

t.Run("returns pins for a known repository when embedded data is loaded", func(t *testing.T) {
all := actionpins.GetActionPins()
if len(all) == 0 {
t.Skip("no embedded pin data available")
}
known := all[0].Repo
known := "actions/checkout"
pins := actionpins.GetActionPinsByRepo(known)
assert.NotEmpty(t, pins, "should return pins for a known repo from embedded data")
})
Expand All @@ -162,11 +152,7 @@ func TestSpec_PublicAPI_GetActionPinByRepo(t *testing.T) {
})

t.Run("returns a pin for a known repository", func(t *testing.T) {
all := actionpins.GetActionPins()
if len(all) == 0 {
t.Skip("no embedded pin data available")
}
known := all[0].Repo
known := "actions/checkout"
pin, ok := actionpins.GetActionPinByRepo(known)
assert.True(t, ok, "should return true for a known repo")
assert.Equal(t, known, pin.Repo, "returned pin should belong to the queried repo")
Expand All @@ -189,12 +175,7 @@ func TestSpec_PublicAPI_ResolveActionPin(t *testing.T) {
// TestSpec_PublicAPI_ResolveLatestActionPin validates latest-version resolution behavior.
func TestSpec_PublicAPI_ResolveLatestActionPin(t *testing.T) {
t.Run("returns latest pinned reference for known repository", func(t *testing.T) {
all := actionpins.GetActionPins()
if len(all) == 0 {
t.Skip("no embedded pin data available")
}

known := all[0].Repo
known := "actions/checkout"
latestPin, ok := actionpins.GetActionPinByRepo(known)
require.True(t, ok, "expected latest pin for known repository")

Expand Down
10 changes: 0 additions & 10 deletions pkg/cli/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,6 @@ type FetchedWorkflow struct {
SourcePath string // The original source path (local path or remote path)
}

// FetchWorkflowFromSource fetches a workflow file directly from GitHub without cloning.
// This is the preferred way to add remote workflows as it only fetches the specific
// files needed rather than cloning the entire repository.
//
// For local workflows (local filesystem paths), it reads from the local filesystem.
// For remote workflows, it uses the GitHub API to fetch the file content.
func FetchWorkflowFromSource(spec *WorkflowSpec, verbose bool) (*FetchedWorkflow, error) {
return FetchWorkflowFromSourceWithContext(context.Background(), spec, verbose)
}

// FetchWorkflowFromSourceWithContext fetches a workflow file from local disk or GitHub.
// The context is used to cancel remote ref resolution retries (for example, on Ctrl-C).
func FetchWorkflowFromSourceWithContext(ctx context.Context, spec *WorkflowSpec, verbose bool) (*FetchedWorkflow, error) {
Expand Down
38 changes: 0 additions & 38 deletions pkg/cli/remote_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,44 +107,6 @@ func TestFetchLocalWorkflow_DirectoryInsteadOfFile(t *testing.T) {
assert.Nil(t, result, "result should be nil on error")
}

func TestFetchWorkflowFromSource_LocalRouting(t *testing.T) {
// Create a temporary local workflow file
tempDir := t.TempDir()
tempFile := filepath.Join(tempDir, "local-workflow.md")
content := "# Local Workflow\n\nTest content."
err := os.WriteFile(tempFile, []byte(content), 0644)
require.NoError(t, err, "should create temp file")

spec := &WorkflowSpec{
WorkflowPath: tempFile,
WorkflowName: "local-workflow",
}

result, err := FetchWorkflowFromSource(spec, false)

require.NoError(t, err, "should not error for local workflow")
assert.True(t, result.IsLocal, "should route to local fetch")
assert.Equal(t, []byte(content), result.Content, "content should match")
}

func TestFetchWorkflowFromSource_RemoteRoutingWithInvalidSlug(t *testing.T) {
// Test with a remote workflow spec that has an invalid slug
spec := &WorkflowSpec{
RepoSpec: RepoSpec{
RepoSlug: "invalid-slug-no-slash",
Version: "main",
},
WorkflowPath: "workflow.md",
WorkflowName: "workflow",
}

result, err := FetchWorkflowFromSource(spec, false)

require.Error(t, err, "should error for invalid repo slug")
assert.Nil(t, result, "result should be nil on error")
assert.Contains(t, err.Error(), "invalid repository slug", "error should mention invalid slug")
}

func TestResolveCommitSHAWithRetries_TransientFailureThenSuccess(t *testing.T) {
originalResolve := resolveRefToSHAForHost
originalWait := waitBeforeSHAResolutionRetry
Expand Down
6 changes: 0 additions & 6 deletions pkg/workflow/action_pins.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,6 @@ func getActionPin(repo string) string {
return actionpins.FormatReference(repo, pins[0].SHA, pins[0].Version)
}

// getActionPins returns all loaded action pins (sorted by version descending).
// Package-private wrapper used by tests in this package.
func getActionPins() []ActionPin {
return actionpins.GetActionPins()
}

// getCachedActionPinFromResolver returns the pinned action reference for repo,
// preferring dynamic resolution via resolver over the embedded pins.
// For use within pkg/workflow when only a resolver is available (no WorkflowData).
Expand Down
153 changes: 0 additions & 153 deletions pkg/workflow/action_pins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ package workflow

import (
"bytes"
"encoding/json"
"os"
"regexp"
"strings"
Expand All @@ -28,75 +27,6 @@ func expectedPinnedUses(t *testing.T, repo, version string) string {
return result
}

// TestActionPinsExist verifies that all action pinning entries exist
func TestActionPinsExist(t *testing.T) {
// Read action pins from JSON file instead of hardcoded list
actionPins := getActionPins()

// Verify we have at least some pins loaded
if len(actionPins) == 0 {
t.Fatal("No action pins loaded from JSON file")
}

// Verify each pin has required fields
for _, pin := range actionPins {
// Verify the pin has a repo
if pin.Repo == "" {
t.Errorf("Action pin has empty Repo field")
continue
}

// Verify the pin has a valid SHA (40 character hex string)
if !isValidSHA(pin.SHA) {
t.Errorf("Invalid SHA for %s: %s (expected 40-character hex string)", pin.Repo, pin.SHA)
}

// Verify the pin has a version
if pin.Version == "" {
t.Errorf("Missing version for %s", pin.Repo)
}
}
}

// TestGetActionPinReturnsValidSHA tests that getActionPin returns valid SHA references
func TestGetActionPinReturnsValidSHA(t *testing.T) {
// Generate test cases dynamically from action pins JSON
actionPins := getActionPins()

if len(actionPins) == 0 {
t.Fatal("No action pins loaded from JSON file")
}

for _, pin := range actionPins {
t.Run(pin.Repo, func(t *testing.T) {
result := getActionPin(pin.Repo)

// Check that the result contains a SHA (40-char hex after @ and before #)
// Format is: repo@sha # version
parts := strings.Split(result, "@")
if len(parts) != 2 {
t.Errorf("getActionPin(%s) = %s, expected format repo@sha # version", pin.Repo, result)
return
}

// Extract SHA (before the comment marker " # ")
shaAndComment := parts[1]
before, _, ok := strings.Cut(shaAndComment, " # ")
if !ok {
t.Errorf("getActionPin(%s) = %s, expected comment with version tag", pin.Repo, result)
return
}

sha := before

// All action pins should have valid SHAs
if !isValidSHA(sha) {
t.Errorf("getActionPin(%s) = %s, expected SHA to be 40-char hex", pin.Repo, result)
}
})
}
}

// TestGetActionPinFallback tests that getActionPin returns empty string for unknown actions
func TestGetActionPinFallback(t *testing.T) {
result := getActionPin("unknown/action")
Expand Down Expand Up @@ -313,53 +243,6 @@ func TestApplyActionPinToStep(t *testing.T) {
}
}

// TestGetActionPinsSorting tests that getActionPins returns sorted action pins
func TestGetActionPinsSorting(t *testing.T) {
pins := getActionPins()

// Dynamically derive the expected count from the JSON file to avoid
// hardcoding a number that breaks when new pins are added or when
// the Go test cache contains a stale binary.
var jsonData ActionPinsData
rawJSON, err := os.ReadFile("../actionpins/data/action_pins.json")
if err != nil {
t.Fatalf("Failed to read action_pins.json: %v", err)
}
if err := json.Unmarshal(rawJSON, &jsonData); err != nil {
t.Fatalf("Failed to parse action_pins.json: %v", err)
}
expectedCount := len(jsonData.Entries)

// Verify we got all the pins from the JSON (catches parsing bugs)
if len(pins) != expectedCount {
t.Errorf("getActionPins() returned %d pins, expected %d (from action_pins.json)", len(pins), expectedCount)
}

// Verify they are sorted by version (descending) then by repository name (ascending)
for i := range len(pins) - 1 {
if pins[i].Version < pins[i+1].Version {
t.Errorf("Pins not sorted correctly by version: %s (v%s) should come before %s (v%s)",
pins[i].Repo, pins[i].Version, pins[i+1].Repo, pins[i+1].Version)
} else if pins[i].Version == pins[i+1].Version && pins[i].Repo > pins[i+1].Repo {
t.Errorf("Pins not sorted correctly by repo name within same version: %s should come before %s",
pins[i].Repo, pins[i+1].Repo)
}
}

// Verify all pins have the required fields
for _, pin := range pins {
if pin.Repo == "" {
t.Error("Found pin with empty Repo field")
}
if pin.Version == "" {
t.Errorf("Pin %s has empty Version field", pin.Repo)
}
if !isValidSHA(pin.SHA) {
t.Errorf("Pin %s has invalid SHA: %s", pin.Repo, pin.SHA)
}
}
}

// TestGetActionPinByRepo tests the getActionPinByRepo function
func TestGetActionPinByRepo(t *testing.T) {
tests := []struct {
Expand Down Expand Up @@ -886,42 +769,6 @@ func TestApplyActionPinsToTypedSteps(t *testing.T) {
}
}

// TestActionPinsCaching verifies that action pins are cached and not re-parsed
func TestActionPinsCaching(t *testing.T) {
// Reset the cache by creating a new sync.Once
// Note: In production, this is handled automatically by sync.Once

// First call - should load and cache
pins1 := getActionPins()
if len(pins1) == 0 {
t.Fatal("No action pins loaded on first call")
}

// Second call - should return cached data (same slice reference)
pins2 := getActionPins()
if len(pins2) == 0 {
t.Fatal("No action pins loaded on second call")
}

// Verify both calls return the same data
if len(pins1) != len(pins2) {
t.Errorf("Cache returned different number of pins: first=%d, second=%d", len(pins1), len(pins2))
}

// Verify the data is identical by checking a few pins
for i := 0; i < len(pins1) && i < 3; i++ {
if pins1[i].Repo != pins2[i].Repo {
t.Errorf("Pin %d repo mismatch: first=%s, second=%s", i, pins1[i].Repo, pins2[i].Repo)
}
if pins1[i].Version != pins2[i].Version {
t.Errorf("Pin %d version mismatch: first=%s, second=%s", i, pins1[i].Version, pins2[i].Version)
}
if pins1[i].SHA != pins2[i].SHA {
t.Errorf("Pin %d SHA mismatch: first=%s, second=%s", i, pins1[i].SHA, pins2[i].SHA)
}
}
}

// TestGetActionPinWithData_V7ExactMatch verifies that v7 resolves to its exact SHA
func TestGetActionPinWithData_V7ExactMatch(t *testing.T) {
data := &WorkflowData{
Expand Down
Loading