Skip to content
Merged
49 changes: 49 additions & 0 deletions actions/setup/js/allowed_extensions_helpers.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// @ts-check

/**
* @param {string} value
* @returns {boolean}
*/
function isGitHubExpression(value) {
const trimmed = value.trim();
return /^\$\{\{[\s\S]*\}\}$/.test(trimmed);
}
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 — The regex [\s\S]* is correct for matching multiline expressions. Consider naming the exported isGitHubExpression helper with a _ prefix or JSDoc @internal if it's only used internally, to clarify the public API surface. ✅ Logic looks solid!


/**
* @param {string} extValue
* @returns {string}
*/
function normalizeAllowedExtension(extValue) {
const trimmed = extValue.trim();
if (!trimmed) {
return "";
}
if (isGitHubExpression(trimmed)) {
return trimmed;
}
const normalized = trimmed.toLowerCase();
return normalized.startsWith(".") ? normalized : `.${normalized}`;
}

/**
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 test file uses ES module import syntax but the helper uses CommonJS (module.exports). Make sure the test runner is configured for CJS or switch the test to require() to avoid module format mismatches.

* @param {string | undefined} envValue
* @returns {{rawValues: string[], normalizedValues: string[], hasUnresolvedExpression: boolean} | null}
*/
function parseAllowedExtensionsEnv(envValue) {
if (!envValue) {
return null;
}

const rawValues = envValue.split(",").map(extValue => extValue.trim());
return {
rawValues,
normalizedValues: rawValues.map(normalizeAllowedExtension).filter(Boolean),
hasUnresolvedExpression: rawValues.some(isGitHubExpression),
};
}

module.exports = {
isGitHubExpression,
normalizeAllowedExtension,
parseAllowedExtensionsEnv,
};
46 changes: 46 additions & 0 deletions actions/setup/js/allowed_extensions_helpers.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { isGitHubExpression, normalizeAllowedExtension, parseAllowedExtensionsEnv } from "./allowed_extensions_helpers.cjs";

describe("allowed_extensions_helpers", () => {
describe("isGitHubExpression", () => {
it("returns true for full GitHub Actions expression", () => {
expect(isGitHubExpression("${{ inputs.allowed_exts }}")).toBe(true);
});

it("returns false for non-expression text", () => {
expect(isGitHubExpression("prefix ${{ inputs.allowed_exts }}")).toBe(false);
});
});

describe("normalizeAllowedExtension", () => {
it("normalizes case, trims spaces, and adds missing dot", () => {
expect(normalizeAllowedExtension(" PNG ")).toBe(".png");
});

it("returns empty string for blank input", () => {
expect(normalizeAllowedExtension(" ")).toBe("");
});
});

describe("parseAllowedExtensionsEnv", () => {
it("returns null when env value is undefined", () => {
expect(parseAllowedExtensionsEnv(undefined)).toBeNull();
});

it("parses and normalizes literal extension values", () => {
expect(parseAllowedExtensionsEnv("TXT, md")).toEqual({
rawValues: ["TXT", "md"],
normalizedValues: [".txt", ".md"],
hasUnresolvedExpression: false,
});
});

it("detects unresolved GitHub Actions expressions", () => {
expect(parseAllowedExtensionsEnv(".txt,${{ inputs.allowed_exts }}")).toEqual({
rawValues: [".txt", "${{ inputs.allowed_exts }}"],
normalizedValues: [".txt", "${{ inputs.allowed_exts }}"],
hasUnresolvedExpression: true,
});
});
});
});
10 changes: 7 additions & 3 deletions actions/setup/js/safe_outputs_handlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const { ERR_CONFIG, ERR_SYSTEM, ERR_VALIDATION } = require("./error_codes.cjs");
const { findRepoCheckout } = require("./find_repo_checkout.cjs");
const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs");
const { getOrGenerateTemporaryId } = require("./temporary_id.cjs");
const { parseAllowedExtensionsEnv } = require("./allowed_extensions_helpers.cjs");

/**
* Create handlers for safe output tools
Expand Down Expand Up @@ -127,15 +128,18 @@ function createHandlers(server, appendSafeOutput, config = {}) {

// Check file extension - read from environment variable if available
const ext = path.extname(filePath).toLowerCase();
const allowedExts = process.env.GH_AW_ASSETS_ALLOWED_EXTS
? process.env.GH_AW_ASSETS_ALLOWED_EXTS.split(",").map(ext => ext.trim())
const parsedAllowedExts = parseAllowedExtensionsEnv(process.env.GH_AW_ASSETS_ALLOWED_EXTS);
if (parsedAllowedExts?.hasUnresolvedExpression) {
throw new Error(`${ERR_CONFIG}: GH_AW_ASSETS_ALLOWED_EXTS contains unresolved GitHub Actions expression. Ensure expressions resolve before safe outputs validation.`);
}
const allowedExts = parsedAllowedExts
? parsedAllowedExts.normalizedValues
: [
// Default set as specified in problem statement
".png",
".jpg",
".jpeg",
];

if (!allowedExts.includes(ext)) {
throw new Error(`${ERR_VALIDATION}: File extension '${ext}' is not allowed. Allowed extensions: ${allowedExts.join(", ")}`);
}
Expand Down
36 changes: 36 additions & 0 deletions actions/setup/js/safe_outputs_handlers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,42 @@ describe("safe_outputs_handlers", () => {
expect(result.content[0].type).toBe("text");
});

it("should normalize custom allowed extensions", () => {
process.env.GH_AW_ASSETS_BRANCH = "test-branch";
process.env.GH_AW_ASSETS_ALLOWED_EXTS = "TXT, md";

const testFile = path.join(testWorkspaceDir, "test.txt");
fs.writeFileSync(testFile, "test content");

const args = { path: testFile };
const result = handlers.uploadAssetHandler(args);

expect(mockAppendSafeOutput).toHaveBeenCalled();
expect(result.content[0].type).toBe("text");
});

it("should reject unresolved GitHub expression in allowed extensions", () => {
process.env.GH_AW_ASSETS_BRANCH = "test-branch";
process.env.GH_AW_ASSETS_ALLOWED_EXTS = "${{ inputs.allowed_exts }}";

const testFile = path.join(testWorkspaceDir, "test.txt");
fs.writeFileSync(testFile, "test content");

const args = { path: testFile };
expect(() => handlers.uploadAssetHandler(args)).toThrow("contains unresolved GitHub Actions expression");
});

it("should reject unresolved expression even when literal extension also matches", () => {
process.env.GH_AW_ASSETS_BRANCH = "test-branch";
process.env.GH_AW_ASSETS_ALLOWED_EXTS = ".txt,${{ inputs.allowed_exts }}";

const testFile = path.join(testWorkspaceDir, "test.txt");
fs.writeFileSync(testFile, "test content");

const args = { path: testFile };
expect(() => handlers.uploadAssetHandler(args)).toThrow("contains unresolved GitHub Actions expression");
});

it("should reject file exceeding size limit", () => {
process.env.GH_AW_ASSETS_BRANCH = "test-branch";
process.env.GH_AW_ASSETS_MAX_SIZE_KB = "1"; // 1 KB limit
Expand Down
1 change: 1 addition & 0 deletions actions/setup/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ SAFE_OUTPUTS_FILES=(
"safe_outputs_tools_loader.cjs"
"safe_outputs_config.cjs"
"safe_outputs_handlers.cjs"
"allowed_extensions_helpers.cjs"
"safe_outputs_append.cjs"
"mcp_server_core.cjs"
"mcp_logger.cjs"
Expand Down
33 changes: 32 additions & 1 deletion pkg/workflow/publish_assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package workflow
import (
"errors"
"fmt"
"regexp"
"strings"

"github.com/github/gh-aw/pkg/constants"
Expand All @@ -11,6 +12,27 @@ import (
)

var publishAssetsLog = logger.New("workflow:publish_assets")
var githubExpressionPattern = regexp.MustCompile(`(?s)^\$\{\{.*\}\}$`)

func isGitHubExpression(value string) bool {
trimmed := strings.TrimSpace(value)
return githubExpressionPattern.MatchString(trimmed)
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 commentisGitHubExpression in Go mirrors the JS helper nicely. The (?s) flag in the regex makes . match newlines, which is correct for multiline expressions like $\{\{ steps.x.outputs\ny }}. 💡 Consider exporting this as IsGitHubExpression if other packages need it in the future.

}

func normalizeAllowedExtension(extension string) string {
trimmed := strings.TrimSpace(extension)
if trimmed == "" {
return ""
}
if isGitHubExpression(trimmed) {
return trimmed
}
normalized := strings.ToLower(trimmed)
if !strings.HasPrefix(normalized, ".") {
normalized = "." + normalized
}
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.

normalizeAllowedExtension treats a single dot (".") as a valid extension and will return ".". This is unlikely to be a meaningful file extension and can lead to confusing or overly-broad allowlists if downstream validation uses suffix checks. Consider treating "." (and possibly other non-extension values like "..") as invalid by returning "" so it gets skipped during parsing.

Suggested change
}
}
if strings.Trim(normalized, ".") == "" {
return ""
}

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.

👋 Smoke test agent was here — this is a great catch! The bare-dot edge case is easy to overlook. Agreed that rejecting . and .. would be a safer default.

📰 BREAKING: Report filed by Smoke Copilot · ● 1.1M

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 normalizeAllowedExtension function returns "." for a single-dot input, which could create misleading allowlists. Consider rejecting bare dots: if strings.Trim(normalized, ".") == "" { return "" }

return normalized
}

// UploadAssetsConfig holds configuration for publishing assets to an orphaned git branch
type UploadAssetsConfig struct {
Expand Down Expand Up @@ -59,9 +81,18 @@ func (c *Compiler) parseUploadAssetConfig(outputMap map[string]any) *UploadAsset
if allowedExts, exists := configMap["allowed-exts"]; exists {
if allowedExtsArray, ok := allowedExts.([]any); ok {
var extStrings []string
seen := make(map[string]struct{})
for _, ext := range allowedExtsArray {
if extStr, ok := ext.(string); ok {
extStrings = append(extStrings, extStr)
normalized := normalizeAllowedExtension(extStr)
if normalized == "" {
continue
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
extStrings = append(extStrings, normalized)
}
}
if len(extStrings) > 0 {
Expand Down
36 changes: 36 additions & 0 deletions pkg/workflow/publish_assets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,34 @@ func TestParseUploadAssetConfig(t *testing.T) {
BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("5")},
},
},
{
name: "upload-asset config normalizes allowed-exts without dots",
input: map[string]any{
"upload-asset": map[string]any{
"allowed-exts": []any{"png", " SVG ", ".jpg", "png"},
},
},
expected: &UploadAssetsConfig{
BranchName: "assets/${{ github.workflow }}",
MaxSizeKB: 10240,
AllowedExts: []string{".png", ".svg", ".jpg"},
BaseSafeOutputConfig: BaseSafeOutputConfig{},
},
},
{
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.

The new normalization behavior is covered for missing dots, whitespace, casing, and duplicates, but there isn't a test case for edge inputs like "." or whitespace-only strings. Adding such a case would clarify the intended behavior (e.g., whether these entries should be ignored) and prevent regressions as normalization rules evolve.

Suggested change
{
{
name: "upload-asset config ignores empty allowed-exts entries",
input: map[string]any{
"upload-asset": map[string]any{
"allowed-exts": []any{".", " ", "\t", " GIF ", "."},
},
},
expected: &UploadAssetsConfig{
BranchName: "assets/${{ github.workflow }}",
MaxSizeKB: 10240,
AllowedExts: []string{".gif"},
BaseSafeOutputConfig: BaseSafeOutputConfig{},
},
},
{

Copilot uses AI. Check for mistakes.
name: "upload-asset config preserves github actions expressions in allowed-exts",
input: map[string]any{
"upload-asset": map[string]any{
"allowed-exts": []any{"${{ inputs.allowed_exts }}", " PNG "},
},
},
expected: &UploadAssetsConfig{
BranchName: "assets/${{ github.workflow }}",
MaxSizeKB: 10240,
AllowedExts: []string{"${{ inputs.allowed_exts }}", ".png"},
BaseSafeOutputConfig: BaseSafeOutputConfig{},
},
},
{
name: "no upload-asset config",
input: map[string]any{},
Expand Down Expand Up @@ -88,6 +116,14 @@ func TestParseUploadAssetConfig(t *testing.T) {
if len(result.AllowedExts) != len(tt.expected.AllowedExts) {
t.Errorf("AllowedExts length: expected %d, got %d", len(tt.expected.AllowedExts), len(result.AllowedExts))
}
for i := range tt.expected.AllowedExts {
if i >= len(result.AllowedExts) {
break
}
if result.AllowedExts[i] != tt.expected.AllowedExts[i] {
t.Errorf("AllowedExts[%d]: expected %s, got %s", i, tt.expected.AllowedExts[i], result.AllowedExts[i])
}
}
})
}
}
Expand Down
26 changes: 26 additions & 0 deletions pkg/workflow/upload_assets_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,29 @@ func TestUploadAssetsConfigCustomExtensions(t *testing.T) {
t.Errorf("Expected custom max size 1024, got %d", config.MaxSizeKB)
}
}

func TestUploadAssetsConfigNormalizesExtensions(t *testing.T) {
compiler := NewCompiler()

outputMap := map[string]any{
"upload-asset": map[string]any{
"allowed-exts": []any{"png", " SVG ", ".jpg", "png"},
},
}

config := compiler.parseUploadAssetConfig(outputMap)
if config == nil {
t.Fatal("Expected config to be created")
}

expectedExts := []string{".png", ".svg", ".jpg"}
if len(config.AllowedExts) != len(expectedExts) {
t.Fatalf("Expected %d normalized extensions, got %d", len(expectedExts), len(config.AllowedExts))
}

for i, ext := range expectedExts {
if config.AllowedExts[i] != ext {
t.Errorf("Expected extension %s at position %d, got %s", ext, i, config.AllowedExts[i])
}
}
}
Loading