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,163 changes: 1,163 additions & 0 deletions .github/workflows/smoke-service-ports.lock.yml

Large diffs are not rendered by default.

86 changes: 86 additions & 0 deletions .github/workflows/smoke-service-ports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
description: Smoke test to validate --allow-host-service-ports with Redis service container
on:
workflow_dispatch:
schedule: daily
status-comment: true
permissions:
contents: read
issues: read
pull-requests: read
name: Smoke Service Ports
engine: copilot
strict: true
services:
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
network:
allowed:
- defaults
- github
tools:
bash:
- "*"
safe-outputs:
allowed-domains: [default-safe-outputs]
add-comment:
hide-older-comments: true
max: 2
messages:
footer: "> 🔌 *Service ports validation by [{workflow_name}]({run_url})*{history_link}"
run-started: "🔌 Starting service ports validation... [{workflow_name}]({run_url}) is testing Redis connectivity..."
run-success: "✅ Service ports validation passed! [{workflow_name}]({run_url}) confirms agent can reach Redis."
run-failure: "❌ Service ports validation failed! [{workflow_name}]({run_url}) could not reach Redis: {status}"
timeout-minutes: 5
---

# Smoke Test: Service Ports (Redis)

**Purpose:** Validate that the `--allow-host-service-ports` feature works end-to-end. The compiler should have automatically detected the Redis service port and configured AWF to allow traffic to it.

**IMPORTANT:** Inside AWF's sandbox, you must connect to services via `host.docker.internal` (not `localhost`). The service containers run on the host, and AWF routes traffic through the host gateway. Since the workflow maps port 6379:6379, port 6379 should work. Keep all outputs concise.

## Required Tests

1. **Redis PING**: Run `redis-cli -h host.docker.internal -p 6379 ping` or `echo PING | nc host.docker.internal 6379` and verify the response contains `PONG`.

2. **Redis SET/GET**: Write a value to Redis and read it back:
- `redis-cli -h host.docker.internal -p 6379 SET smoke_test "service-ports-ok"`
- `redis-cli -h host.docker.internal -p 6379 GET smoke_test`
- Verify the returned value is `service-ports-ok`

3. **Redis INFO**: Run `redis-cli -h host.docker.internal -p 6379 INFO server | head -5` to verify we can query Redis server info.

## Output Requirements

Add a **concise comment** to the pull request (if triggered by PR) with:

- Each test with a pass/fail status
- Overall status: PASS or FAIL
- Note whether `redis-cli` was available or if `nc`/netcat was used as fallback

Example:
```
## Service Ports Smoke Test (Redis)

| Test | Status |
|------|--------|
| Redis PING | ✅ PONG received |
| Redis SET/GET | ✅ Value round-tripped |
| Redis INFO | ✅ Server info retrieved |

**Result:** 3/3 tests passed ✅
```

**Important**: If no action is needed after completing your analysis, you **MUST** call the `noop` safe-output tool with a brief explanation.

```json
{"noop": {"message": "No action needed: [brief explanation of what was analyzed and why]"}}
```
153 changes: 153 additions & 0 deletions pkg/cli/compile_service_ports_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//go:build integration

package cli

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

// TestCompileServicePortsWorkflow compiles the canonical test-service-ports.md workflow
// and verifies that the generated lock file contains --allow-host-service-ports with the
// correct ${{ job.services['<id>'].ports['<port>'] }} expressions for every service port.
func TestCompileServicePortsWorkflow(t *testing.T) {
setup := setupIntegrationTest(t)
defer setup.cleanup()

srcPath := filepath.Join(projectRoot, "pkg/cli/workflows/test-service-ports.md")
dstPath := filepath.Join(setup.workflowsDir, "test-service-ports.md")

srcContent, err := os.ReadFile(srcPath)
if err != nil {
t.Fatalf("Failed to read source workflow %s: %v", srcPath, err)
}
if err := os.WriteFile(dstPath, srcContent, 0644); err != nil {
t.Fatalf("Failed to write workflow to test dir: %v", err)
}

cmd := exec.Command(setup.binaryPath, "compile", dstPath)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Compile failed: %v\nOutput: %s", err, string(output))
}

lockFilePath := filepath.Join(setup.workflowsDir, "test-service-ports.lock.yml")
lockContent, err := os.ReadFile(lockFilePath)
if err != nil {
t.Fatalf("Failed to read lock file: %v", err)
}
lock := string(lockContent)

// The compiler must emit --allow-host-service-ports
if !strings.Contains(lock, "--allow-host-service-ports") {
t.Errorf("Lock file missing --allow-host-service-ports\nLock content:\n%s", lock)
}

// Bracket-notation expressions must be present for both services
for _, expr := range []string{
"job.services['postgres'].ports['5432']",
"job.services['redis'].ports['6379']",
} {
if !strings.Contains(lock, expr) {
t.Errorf("Lock file missing expected expression %q\nLock content:\n%s", expr, lock)
}
}

t.Logf("test-service-ports.md compiled successfully; --allow-host-service-ports verified")
}

// TestCompileServicePorts_NoServices verifies that a workflow with no services block
// compiles without errors and does NOT emit --allow-host-service-ports.
func TestCompileServicePorts_NoServices(t *testing.T) {
setup := setupIntegrationTest(t)
defer setup.cleanup()

testWorkflow := `---
on:
workflow_dispatch:
permissions:
contents: read
engine: copilot
---

# No Services Workflow

This workflow has no services block and should not include --allow-host-service-ports.
`
testPath := filepath.Join(setup.workflowsDir, "no-services.md")
if err := os.WriteFile(testPath, []byte(testWorkflow), 0644); err != nil {
t.Fatalf("Failed to write workflow: %v", err)
}

cmd := exec.Command(setup.binaryPath, "compile", testPath)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Compile failed: %v\nOutput: %s", err, string(output))
}

lockFilePath := filepath.Join(setup.workflowsDir, "no-services.lock.yml")
lockContent, err := os.ReadFile(lockFilePath)
if err != nil {
t.Fatalf("Failed to read lock file: %v", err)
}

if strings.Contains(string(lockContent), "--allow-host-service-ports") {
t.Errorf("Lock file should NOT contain --allow-host-service-ports when no services are defined")
}
}

// TestCompileServicePorts_HyphenatedServiceID verifies that service IDs containing
// hyphens are emitted with bracket notation (not dot notation) in the compiled lock file.
func TestCompileServicePorts_HyphenatedServiceID(t *testing.T) {
setup := setupIntegrationTest(t)
defer setup.cleanup()

testWorkflow := `---
on:
workflow_dispatch:
permissions:
contents: read
engine: copilot
services:
my-postgres:
image: postgres:15
ports:
- 5432:5432
---

# Hyphenated Service ID Workflow

Verifies bracket notation for hyphenated service IDs.
`
testPath := filepath.Join(setup.workflowsDir, "hyphenated-service.md")
if err := os.WriteFile(testPath, []byte(testWorkflow), 0644); err != nil {
t.Fatalf("Failed to write workflow: %v", err)
}

cmd := exec.Command(setup.binaryPath, "compile", testPath)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Compile failed: %v\nOutput: %s", err, string(output))
}

lockFilePath := filepath.Join(setup.workflowsDir, "hyphenated-service.lock.yml")
lockContent, err := os.ReadFile(lockFilePath)
if err != nil {
t.Fatalf("Failed to read lock file: %v", err)
}
lock := string(lockContent)

// Must use bracket notation, not dot notation
bracketNotation := "job.services['my-postgres'].ports['5432']"
dotNotation := "job.services.my-postgres.ports"

if !strings.Contains(lock, bracketNotation) {
t.Errorf("Lock file missing bracket-notation expression %q\nLock content:\n%s", bracketNotation, lock)
}
if strings.Contains(lock, dotNotation) {
t.Errorf("Lock file must NOT use dot notation for hyphenated service IDs; found %q\nLock content:\n%s", dotNotation, lock)
}
}
24 changes: 24 additions & 0 deletions pkg/cli/workflows/test-service-ports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
on:
workflow_dispatch:
permissions:
contents: read
engine: copilot
services:
postgres:
image: postgres:15
ports:
- 5432:5432
redis:
image: redis:7
ports:
- 6379:6379
---

# Test Service Ports

This workflow tests that the compiler automatically generates `--allow-host-service-ports`
from `services:` port mappings.

Expected: the compiled lock file includes `--allow-host-service-ports` with expressions for
both PostgreSQL (port 5432) and Redis (port 6379).
10 changes: 10 additions & 0 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ func BuildAWFCommand(config AWFCommandConfig) string {
ghAwDir, ghAwDir, ghAwDir, ghAwDir,
)

// Add --allow-host-service-ports for services with port mappings.
// This is appended as a raw (expandable) arg because the value contains
// ${{ job.services.<id>.ports['<port>'] }} expressions that include single quotes.
// These expressions are resolved by the GitHub Actions runner before shell execution,
// so they must not be shell-escaped.
if config.WorkflowData != nil && config.WorkflowData.ServicePortExpressions != "" {
expandableArgs += fmt.Sprintf(` --allow-host-service-ports "%s"`, config.WorkflowData.ServicePortExpressions)
awfHelpersLog.Printf("Added --allow-host-service-ports with %s", config.WorkflowData.ServicePortExpressions)
}

// Wrap engine command in shell (command already includes any internal setup like npm PATH)
shellWrappedCommand := WrapCommandInShell(config.EngineCommand)

Expand Down
16 changes: 16 additions & 0 deletions pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"encoding/json"
"fmt"
"maps"
"os"
"strings"

"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/parser"
"github.com/goccy/go-yaml"
Expand Down Expand Up @@ -529,6 +531,20 @@ func (c *Compiler) processAndMergeServices(frontmatter map[string]any, workflowD
}
}
}

// Extract service port expressions for AWF --allow-host-service-ports
if workflowData.Services != "" {
expressions, warnings := ExtractServicePortExpressions(workflowData.Services)
workflowData.ServicePortExpressions = expressions
for _, w := range warnings {
orchestratorWorkflowLog.Printf("Warning: %s", w)
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(w))
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Warnings are emitted to stderr here but the compiler’s warning counter isn’t incremented. To keep summary/exit behavior consistent with other warnings (e.g. pkg/workflow/compiler.go), call c.IncrementWarningCount() for each emitted warning.

Suggested change
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(w))
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(w))
c.IncrementWarningCount()

Copilot uses AI. Check for mistakes.
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.

Agreed — incrementing c.IncrementWarningCount() is important for consistent warning summary behavior across the compiler. Good catch! 🔍

📰 BREAKING: Report filed by Smoke Copilot

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.

Warnings emitted here should also call c.IncrementWarningCount() to keep the warning counter consistent with the rest of the compiler's warning tracking behavior.

c.IncrementWarningCount()
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.

🤖 Smoke test review comment #2: Debug-only log here means users won't see the warning unless ACTIONS_RUNNER_DEBUG is set. Consider also emitting to stderr so this is visible during normal compilation, consistent with other warnings in this package.

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.

Agreed — incrementing c.IncrementWarningCount() is important for consistent warning summary behavior across the compiler. Good catch! 🔍

📰 BREAKING: Report filed by Smoke Copilot

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.

🤖 Smoke test review comment #2: Debug-only log here means users won't see the warning unless ACTIONS_RUNNER_DEBUG is set. Consider also emitting to stderr so this is visible during normal compilation, consistent with other warnings in this package.

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.

Agreed — incrementing c.IncrementWarningCount() is important for consistent warning summary behavior across the compiler. Good catch! 🔍

📰 BREAKING: Report filed by Smoke Copilot

📰 BREAKING: Report filed by Smoke Copilot

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.

🤖 Smoke test review comment #2: Debug-only log here means users won't see the warning unless ACTIONS_RUNNER_DEBUG is set. Consider also emitting to stderr so this is visible during normal compilation, consistent with other warnings in this package.

}
Comment on lines +535 to +543
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

These are meant to be compile-time warnings, but they’re currently only emitted via the debug logger (visible only when DEBUG/ACTIONS_RUNNER_DEBUG is enabled). To match existing warning behavior in this package (e.g., pkg/workflow/action_pins.go prints console-formatted warnings to stderr), consider emitting these warnings through the same user-visible warning mechanism instead of (or in addition to) debug logs.

Copilot uses AI. Check for mistakes.
if expressions != "" {
orchestratorWorkflowLog.Printf("Extracted service port expressions: %s", expressions)
}
}
}

// mergeJobsFromYAMLImports merges jobs from imported YAML workflows with main workflow jobs
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ type WorkflowData struct {
IsDetectionRun bool // true when this WorkflowData is used for inline threat detection (not the main agent run)
UpdateCheckDisabled bool // true when check-for-updates: false is set in frontmatter (disables version check step in activation job)
EngineConfigSteps []map[string]any // steps returned by engine.RenderConfig — prepended before execution steps
ServicePortExpressions string // comma-separated ${{ job.services['<id>'].ports['<port>'] }} expressions for AWF --allow-host-service-ports
}

// BaseSafeOutputConfig holds common configuration fields for all safe output types
Expand Down
Loading
Loading