From 6ada2186548ef9989a5d74810aa678f6a374f6e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 23:11:27 +0000 Subject: [PATCH 01/26] Plan OTLP OIDC minting support Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/dead-code-remover.lock.yml | 2 +- pkg/workflow/codex_engine.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dead-code-remover.lock.yml b/.github/workflows/dead-code-remover.lock.yml index 77e5e78dd1f..303c4696250 100644 --- a/.github/workflows/dead-code-remover.lock.yml +++ b/.github/workflows/dead-code-remover.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e801c5393a54735b3e4a537d800eb9368f7e1e26cd0737da4e3c7765aa958558","body_hash":"9cbe7918a51ef9794ae9ecd5e9268ee7598d3c99d299c33bc9497876f0f61162","strict":true,"agent_id":"copilot"} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e801c5393a54735b3e4a537d800eb9368f7e1e26cd0737da4e3c7765aa958558","body_hash":"726cc121be15d21e074fcab7dd84c49788b5d158c268593d02a40c8602b2df23","strict":true,"agent_id":"copilot"} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"4a3601121dd01d1626a1e23e37211e3254c1c06c","version":"v6.4.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.55"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.55"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.55"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.55"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.19"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4","digest":"sha256:e3816a476a977cfb836e7d221510011436c654d11861db66ecfd826601aba6a4","pinned_image":"ghcr.io/github/github-mcp-server:v1.0.4@sha256:e3816a476a977cfb836e7d221510011436c654d11861db66ecfd826601aba6a4"},{"image":"node:lts-alpine","digest":"sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14","pinned_image":"node:lts-alpine@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14"}]} # ___ _ _ # / _ \ | | (_) diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index 6c118a686ea..9cf5235ca14 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -347,9 +347,9 @@ mkdir -p "$CODEX_HOME/logs" "GH_AW_MCP_CONFIG": "${{ runner.temp }}/gh-aw/mcp-config/config.toml", // Keep Codex runtime state in /tmp/gh-aw because ${RUNNER_TEMP}/gh-aw is // mounted read-only inside the AWF chroot sandbox. - "CODEX_HOME": "/tmp/gh-aw/mcp-config", + "CODEX_HOME": "/tmp/gh-aw/mcp-config", // Enable verbose RUST_LOG only in debug mode (runner.debug == 1); default to warn to avoid noisy output. - "RUST_LOG": "${{ runner.debug == 1 && 'trace,hyper_util=info,mio=info,reqwest=info,os_info=info,codex_otel=warn,codex_core=debug,ocodex_exec=debug' || 'warn' }}", + "RUST_LOG": "${{ runner.debug == 1 && 'trace,hyper_util=info,mio=info,reqwest=info,os_info=info,codex_otel=warn,codex_core=debug,ocodex_exec=debug' || 'warn' }}", "GH_AW_GITHUB_TOKEN": effectiveGitHubToken, "GITHUB_PERSONAL_ACCESS_TOKEN": effectiveGitHubToken, // Used by GitHub MCP server via env_vars "OPENAI_API_KEY": "${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }}", // Fallback for CODEX_API_KEY From 32c359480e36a1bc296fc2b9d809adc3be40dfa1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 23:16:30 +0000 Subject: [PATCH 02/26] Add OTLP github-oidc pre-setup minting wiring Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/syntax.md | 6 ++ actions/setup/action.yml | 4 + actions/setup/index.js | 3 + actions/setup/js/action_setup_otlp.cjs | 12 +++ actions/setup/js/action_setup_otlp.test.cjs | 27 ++++++ pkg/parser/schemas/main_workflow_schema.json | 17 ++++ pkg/workflow/compiler_yaml_step_generation.go | 75 +++++++++++---- pkg/workflow/frontmatter_types.go | 19 ++++ pkg/workflow/observability_otlp.go | 91 +++++++++++++++++++ pkg/workflow/observability_otlp_test.go | 66 ++++++++++++++ pkg/workflow/setup_step_version_test.go | 60 ++++++++++++ 11 files changed, 361 insertions(+), 19 deletions(-) diff --git a/.github/aw/syntax.md b/.github/aw/syntax.md index 8876f2fa72f..d1268a97d60 100644 --- a/.github/aw/syntax.md +++ b/.github/aw/syntax.md @@ -290,6 +290,9 @@ The YAML frontmatter supports these fields: - **`observability:`** - Workflow observability and telemetry configuration (object) - **`otlp:`** - Export OpenTelemetry spans to any OTLP-compatible backend (Honeycomb, Grafana Tempo, Sentry, etc.) (object) - `endpoint:` - OTLP collector endpoint URL. When a static URL is provided, its hostname is added to the AWF firewall allowlist automatically. Supports GitHub Actions expressions. + - `auth:` - Optional runtime auth configuration. + - `type:` - `github-oidc` to mint a GitHub Actions OIDC credential before `actions/setup` and use it for OTLP Authorization headers. + - `audience:` - Optional OIDC audience passed to `core.getIDToken(audience)`. - `headers:` - Comma-separated `key=value` HTTP headers included in every OTLP export request (e.g. `Authorization=Bearer `). Injected as `OTEL_EXPORTER_OTLP_HEADERS`. Supports GitHub Actions expressions. - Example: @@ -297,6 +300,9 @@ The YAML frontmatter supports these fields: observability: otlp: endpoint: ${{ secrets.GH_AW_OTEL_ENDPOINT }} + auth: + type: github-oidc + audience: api://AzureADTokenExchange headers: ${{ secrets.GH_AW_OTEL_HEADERS }} ``` diff --git a/actions/setup/action.yml b/actions/setup/action.yml index d70cc20d41a..bf9b64ec7a7 100644 --- a/actions/setup/action.yml +++ b/actions/setup/action.yml @@ -22,6 +22,10 @@ inputs: description: 'OTLP parent span ID (16-character hexadecimal string) for the setup span. Pass the setup-span-id output of the upstream setup step so job setup spans form a single tree.' required: false default: '' + otlp-oidc-token: + description: 'Optional pre-minted OIDC bearer token used for OTLP Authorization headers.' + required: false + default: '' outputs: files_copied: diff --git a/actions/setup/index.js b/actions/setup/index.js index b808485302f..7a37e776369 100644 --- a/actions/setup/index.js +++ b/actions/setup/index.js @@ -16,6 +16,7 @@ const safeOutputArtifactClient = getActionInput("SAFE_OUTPUT_ARTIFACT_CLIENT") | const inputTraceId = getActionInput("TRACE_ID"); const inputParentSpanId = getActionInput("PARENT_SPAN_ID"); const inputJobName = getActionInput("JOB_NAME"); +const inputOTLPOIDCToken = getActionInput("OTLP_OIDC_TOKEN"); const result = spawnSync(path.join(__dirname, "setup.sh"), [], { stdio: "inherit", @@ -25,6 +26,7 @@ const result = spawnSync(path.join(__dirname, "setup.sh"), [], { INPUT_TRACE_ID: inputTraceId, INPUT_PARENT_SPAN_ID: inputParentSpanId, INPUT_JOB_NAME: inputJobName, + INPUT_OTLP_OIDC_TOKEN: inputOTLPOIDCToken, // Tell setup.sh to skip the OTLP span: in action mode index.js sends it // after setup.sh returns so that the startMs captured here is used. GH_AW_SKIP_SETUP_OTLP: "1", @@ -53,6 +55,7 @@ if (result.status !== 0) { process.env.INPUT_TRACE_ID = inputTraceId; process.env.INPUT_PARENT_SPAN_ID = inputParentSpanId; process.env.INPUT_JOB_NAME = inputJobName; + process.env.INPUT_OTLP_OIDC_TOKEN = inputOTLPOIDCToken; const { run } = require(path.join(__dirname, "js", "action_setup_otlp.cjs")); await run(); } catch { diff --git a/actions/setup/js/action_setup_otlp.cjs b/actions/setup/js/action_setup_otlp.cjs index 2fc39549d40..e69a854a1f2 100644 --- a/actions/setup/js/action_setup_otlp.cjs +++ b/actions/setup/js/action_setup_otlp.cjs @@ -86,6 +86,18 @@ async function run() { process.env.INPUT_PARENT_SPAN_ID = inputParentSpanId; } + const inputOTLPOIDCToken = getActionInput("OTLP_OIDC_TOKEN"); + if (inputOTLPOIDCToken) { + const existingHeaders = process.env.OTEL_EXPORTER_OTLP_HEADERS || ""; + const hasAuthorizationHeader = /(^|,)\s*authorization\s*=/i.test(existingHeaders); + const mergedHeaders = hasAuthorizationHeader + ? existingHeaders + : (existingHeaders ? `${existingHeaders},` : "") + "Authorization=Bearer " + inputOTLPOIDCToken; + + process.env.OTEL_EXPORTER_OTLP_HEADERS = mergedHeaders; + writeEnvLine(process.env.GITHUB_ENV, "OTEL_EXPORTER_OTLP_HEADERS", mergedHeaders, "OTEL_EXPORTER_OTLP_HEADERS", "GITHUB_ENV"); + } + if (!endpoints) { console.log("[otlp] GH_AW_OTLP_ENDPOINTS not set, skipping setup span"); } else { diff --git a/actions/setup/js/action_setup_otlp.test.cjs b/actions/setup/js/action_setup_otlp.test.cjs index b6cb6fe877c..4ec96caf4f7 100644 --- a/actions/setup/js/action_setup_otlp.test.cjs +++ b/actions/setup/js/action_setup_otlp.test.cjs @@ -63,6 +63,8 @@ describe("action_setup_otlp.cjs", () => { "INPUT_JOB-NAME": process.env["INPUT_JOB-NAME"], INPUT_PARENT_SPAN_ID: process.env.INPUT_PARENT_SPAN_ID, "INPUT_PARENT-SPAN-ID": process.env["INPUT_PARENT-SPAN-ID"], + INPUT_OTLP_OIDC_TOKEN: process.env.INPUT_OTLP_OIDC_TOKEN, + OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, }; delete process.env.GH_AW_OTLP_ENDPOINTS; @@ -73,6 +75,9 @@ describe("action_setup_otlp.cjs", () => { delete process.env["INPUT_JOB-NAME"]; delete process.env.INPUT_PARENT_SPAN_ID; delete process.env["INPUT_PARENT-SPAN-ID"]; + delete process.env.INPUT_OTLP_OIDC_TOKEN; + delete process.env["INPUT_OTLP_OIDC_TOKEN"]; + delete process.env.OTEL_EXPORTER_OTLP_HEADERS; process.env.GITHUB_OUTPUT = outputFile; process.env.GITHUB_ENV = envFile; }); @@ -147,6 +152,28 @@ describe("action_setup_otlp.cjs", () => { }); }); + describe("OTLP OIDC token header injection", () => { + it("injects Authorization header and exports it to GITHUB_ENV when INPUT_OTLP_OIDC_TOKEN is set", async () => { + const minted = "oidc" + "-" + "token" + "-" + "value"; + process.env.INPUT_OTLP_OIDC_TOKEN = minted; + + await run(); + + expect(process.env.OTEL_EXPORTER_OTLP_HEADERS).toContain("Authorization=Bearer "); + expect(process.env.OTEL_EXPORTER_OTLP_HEADERS).toContain(minted); + expect(readFileSync(envFile, "utf8")).toContain("OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer "); + }); + + it("does not override existing Authorization header", async () => { + process.env.INPUT_OTLP_OIDC_TOKEN = "oidc" + "-second" + "-value"; + process.env.OTEL_EXPORTER_OTLP_HEADERS = "Authorization=******"; + + await run(); + + expect(process.env.OTEL_EXPORTER_OTLP_HEADERS).toBe("Authorization=******"); + }); + }); + describe("SETUP_START_MS propagation", () => { it("should pass startMs=0 when SETUP_START_MS is not set", async () => { await run(); diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 7e4a8b7a56b..97c53e409b6 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9668,6 +9668,23 @@ "enum": ["error", "warn", "ignore"], "default": "error", "description": "How to handle missing OTLP endpoint/header values at runtime (for example from unset secrets). 'error' fails workflow startup (default), 'warn' logs a warning and skips MCP gateway OTLP configuration, and 'ignore' skips MCP gateway OTLP configuration without warning. This affects MCP gateway setup only; workflow-level OTEL_* environment variables are still injected." + }, + "auth": { + "type": "object", + "description": "Optional runtime authentication for OTLP export.", + "properties": { + "type": { + "type": "string", + "enum": ["github-oidc"], + "description": "Authentication type. 'github-oidc' mints a GitHub Actions OIDC credential before actions/setup and uses it in the OTLP Authorization header." + }, + "audience": { + "type": "string", + "description": "Optional OIDC audience passed to core.getIDToken(audience) when auth.type is github-oidc." + } + }, + "required": ["type"], + "additionalProperties": false } }, "additionalProperties": false diff --git a/pkg/workflow/compiler_yaml_step_generation.go b/pkg/workflow/compiler_yaml_step_generation.go index ad797c704fb..de25db1fd8c 100644 --- a/pkg/workflow/compiler_yaml_step_generation.go +++ b/pkg/workflow/compiler_yaml_step_generation.go @@ -128,7 +128,36 @@ func setupParentSpanNeedsExpr(upstreamJob constants.JobName) string { return fmt.Sprintf("${{ needs.%s.outputs.setup-parent-span-id || needs.%s.outputs.setup-span-id }}", upstreamJob, upstreamJob) } +func (c *Compiler) generateOTLPOIDCMintStep(data *WorkflowData) []string { + if data == nil || !hasOTLPGitHubOIDCAuth(data.ParsedFrontmatter, data.RawFrontmatter) { + return nil + } + + compilerYamlStepGenerationLog.Print("Generating OTLP OIDC token mint step before setup") + lines := []string{ + " - name: Mint OTLP OIDC token\n", + " id: mint-otlp-oidc-token\n", + fmt.Sprintf(" uses: %s\n", getCachedActionPin("actions/github-script", data)), + " with:\n", + " script: |\n", + " const audience = (process.env.GH_AW_OTLP_OIDC_AUDIENCE || '').trim();\n", + " const token = audience ? await core.getIDToken(audience) : await core.getIDToken();\n", + " core.setSecret(token);\n", + " core.setOutput('token', token);\n", + } + + if audience := getOTLPGitHubOIDCAudience(data.ParsedFrontmatter, data.RawFrontmatter); audience != "" { + lines = append(lines, " env:\n") + lines = append(lines, formatYAMLEnv(" ", "GH_AW_OTLP_OIDC_AUDIENCE", audience)) + } + + return lines +} + func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, destination string, enableArtifactClient bool, traceID string, parentSpanID string) []string { + lines := c.generateOTLPOIDCMintStep(data) + hasOTLPOIDC := len(lines) > 0 + setupEngineID := "" if data != nil { if data.EngineConfig != nil && data.EngineConfig.ID != "" { @@ -140,7 +169,7 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, // Script mode: run the setup.sh script directly if c.actionMode.IsScript() { - lines := []string{ + setupLines := []string{ " - name: Setup Scripts\n", " id: setup\n", " run: |\n", @@ -150,38 +179,42 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, " INPUT_JOB_NAME: ${{ github.job }}\n", } if data != nil { - lines = append(lines, + setupLines = append(setupLines, fmt.Sprintf(" GH_AW_SETUP_WORKFLOW_NAME: %q\n", data.Name), fmt.Sprintf(" GH_AW_CURRENT_WORKFLOW_REF: %s\n", buildSetupWorkflowRefExpr(data)), ) if v := getVersionForSetup(data); v != "" { - lines = append(lines, fmt.Sprintf(" GH_AW_INFO_VERSION: %q\n", v)) + setupLines = append(setupLines, fmt.Sprintf(" GH_AW_INFO_VERSION: %q\n", v)) } if v := getAWFVersionForSetup(data); v != "" { - lines = append(lines, fmt.Sprintf(" GH_AW_INFO_AWF_VERSION: %q\n", v)) + setupLines = append(setupLines, fmt.Sprintf(" GH_AW_INFO_AWF_VERSION: %q\n", v)) } if data.Source != "" { - lines = append(lines, " GH_AW_INFO_BODY_MODIFIED: \"false\"\n") + setupLines = append(setupLines, " GH_AW_INFO_BODY_MODIFIED: \"false\"\n") } if setupEngineID != "" { - lines = append(lines, fmt.Sprintf(" GH_AW_INFO_ENGINE_ID: %q\n", setupEngineID)) + setupLines = append(setupLines, fmt.Sprintf(" GH_AW_INFO_ENGINE_ID: %q\n", setupEngineID)) } } if traceID != "" { - lines = append(lines, fmt.Sprintf(" INPUT_TRACE_ID: %s\n", traceID)) + setupLines = append(setupLines, fmt.Sprintf(" INPUT_TRACE_ID: %s\n", traceID)) } if parentSpanID != "" { - lines = append(lines, fmt.Sprintf(" INPUT_PARENT_SPAN_ID: %s\n", parentSpanID)) + setupLines = append(setupLines, fmt.Sprintf(" INPUT_PARENT_SPAN_ID: %s\n", parentSpanID)) + } + if hasOTLPOIDC { + setupLines = append(setupLines, " INPUT_OTLP_OIDC_TOKEN: ${{ steps.mint-otlp-oidc-token.outputs.token }}\n") } if enableArtifactClient { - lines = append(lines, " INPUT_SAFE_OUTPUT_ARTIFACT_CLIENT: 'true'\n") + setupLines = append(setupLines, " INPUT_SAFE_OUTPUT_ARTIFACT_CLIENT: 'true'\n") } + lines = append(lines, setupLines...) return lines } // Dev/Release mode: use the setup action compilerYamlStepGenerationLog.Printf("Generating setup step: ref=%s, destination=%s, artifactClient=%t, traceID=%q, parentSpanID=%q", setupActionRef, destination, enableArtifactClient, traceID, parentSpanID) - lines := []string{ + setupLines := []string{ " - name: Setup Scripts\n", " id: setup\n", fmt.Sprintf(" uses: %s\n", setupActionRef), @@ -190,34 +223,38 @@ func (c *Compiler) generateSetupStep(data *WorkflowData, setupActionRef string, " job-name: ${{ github.job }}\n", } if traceID != "" { - lines = append(lines, fmt.Sprintf(" trace-id: %s\n", traceID)) + setupLines = append(setupLines, fmt.Sprintf(" trace-id: %s\n", traceID)) } if parentSpanID != "" { - lines = append(lines, fmt.Sprintf(" parent-span-id: %s\n", parentSpanID)) + setupLines = append(setupLines, fmt.Sprintf(" parent-span-id: %s\n", parentSpanID)) + } + if hasOTLPOIDC { + setupLines = append(setupLines, " otlp-oidc-token: ${{ steps.mint-otlp-oidc-token.outputs.token }}\n") } if enableArtifactClient { - lines = append(lines, " safe-output-artifact-client: 'true'\n") + setupLines = append(setupLines, " safe-output-artifact-client: 'true'\n") } - lines = append(lines, + setupLines = append(setupLines, " env:\n", fmt.Sprintf(" GH_AW_SETUP_WORKFLOW_NAME: %q\n", data.Name), fmt.Sprintf(" GH_AW_CURRENT_WORKFLOW_REF: %s\n", buildSetupWorkflowRefExpr(data)), ) if v := getVersionForSetup(data); v != "" { - lines = append(lines, fmt.Sprintf(" GH_AW_INFO_VERSION: %q\n", v)) + setupLines = append(setupLines, fmt.Sprintf(" GH_AW_INFO_VERSION: %q\n", v)) } if v := getAWFVersionForSetup(data); v != "" { - lines = append(lines, fmt.Sprintf(" GH_AW_INFO_AWF_VERSION: %q\n", v)) + setupLines = append(setupLines, fmt.Sprintf(" GH_AW_INFO_AWF_VERSION: %q\n", v)) } if data.Source != "" { - lines = append(lines, " GH_AW_INFO_BODY_MODIFIED: \"false\"\n") + setupLines = append(setupLines, " GH_AW_INFO_BODY_MODIFIED: \"false\"\n") } if setupEngineID != "" { - lines = append(lines, fmt.Sprintf(" GH_AW_INFO_ENGINE_ID: %q\n", setupEngineID)) + setupLines = append(setupLines, fmt.Sprintf(" GH_AW_INFO_ENGINE_ID: %q\n", setupEngineID)) } if hasWorkflowCallTrigger(data.On) { - lines = append(lines, " GH_AW_SETUP_AW_CONTEXT: ${{ inputs.aw_context }}\n") + setupLines = append(setupLines, " GH_AW_SETUP_AW_CONTEXT: ${{ inputs.aw_context }}\n") } + lines = append(lines, setupLines...) return lines } diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index b4880a46831..77cc2ad2c96 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -205,6 +205,15 @@ type OTLPEndpointConfig struct { Headers any `json:"headers,omitempty"` } +// OTLPAuthConfig holds optional runtime auth configuration for OTLP export. +// Currently supports GitHub Actions OIDC bearer token minting. +type OTLPAuthConfig struct { + // Type is the auth mechanism. Supported value: "github-oidc". + Type string `json:"type,omitempty"` + // Audience is an optional OIDC audience passed to core.getIDToken(audience). + Audience string `json:"audience,omitempty"` +} + // OTLPConfig holds configuration for OTLP (OpenTelemetry Protocol) trace export. type OTLPConfig struct { // Endpoint accepts one of three forms: @@ -249,6 +258,16 @@ type OTLPConfig struct { // langfuse.user.id: "{{ github.actor }}" // user.id: "{{ github.actor }}" Attributes map[string]string `json:"attributes,omitempty"` + + // Auth configures runtime OTLP authentication. + // Supported values: + // auth: + // type: github-oidc + // audience: "api://AzureADTokenExchange" # optional + // + // When configured, gh-aw mints an OIDC token before actions/setup and passes + // it to setup so OTLP requests can include an Authorization bearer token. + Auth *OTLPAuthConfig `json:"auth,omitempty"` } // ObservabilityConfig represents workflow observability options. diff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go index 7779c886b6b..b4e799fe1c0 100644 --- a/pkg/workflow/observability_otlp.go +++ b/pkg/workflow/observability_otlp.go @@ -163,6 +163,97 @@ func getOTLPEndpointEnvValue(config *FrontmatterConfig) string { return "" } +func normalizeOTLPAuthType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "github-oidc": + return "github-oidc" + default: + return "" + } +} + +// getOTLPGitHubOIDCAudience returns observability.otlp.auth.audience when +// auth.type is configured as github-oidc. Returns empty string when auth is +// unset, invalid, or not github-oidc. +func getOTLPGitHubOIDCAudience(config *FrontmatterConfig, frontmatter map[string]any) string { + if config != nil && config.Observability != nil && config.Observability.OTLP != nil && config.Observability.OTLP.Auth != nil { + if normalizeOTLPAuthType(config.Observability.OTLP.Auth.Type) == "github-oidc" { + return strings.TrimSpace(config.Observability.OTLP.Auth.Audience) + } + } + + if frontmatter == nil { + return "" + } + obsAny, ok := frontmatter["observability"] + if !ok { + return "" + } + obsMap, ok := obsAny.(map[string]any) + if !ok { + return "" + } + otlpAny, ok := obsMap["otlp"] + if !ok { + return "" + } + otlpMap, ok := otlpAny.(map[string]any) + if !ok { + return "" + } + authAny, ok := otlpMap["auth"] + if !ok { + return "" + } + authMap, ok := authAny.(map[string]any) + if !ok { + return "" + } + authType, _ := authMap["type"].(string) + if normalizeOTLPAuthType(authType) != "github-oidc" { + return "" + } + audience, _ := authMap["audience"].(string) + return strings.TrimSpace(audience) +} + +func hasOTLPGitHubOIDCAuth(config *FrontmatterConfig, frontmatter map[string]any) bool { + if config != nil && config.Observability != nil && config.Observability.OTLP != nil && config.Observability.OTLP.Auth != nil { + if normalizeOTLPAuthType(config.Observability.OTLP.Auth.Type) == "github-oidc" { + return true + } + } + if frontmatter == nil { + return false + } + obsAny, ok := frontmatter["observability"] + if !ok { + return false + } + obsMap, ok := obsAny.(map[string]any) + if !ok { + return false + } + otlpAny, ok := obsMap["otlp"] + if !ok { + return false + } + otlpMap, ok := otlpAny.(map[string]any) + if !ok { + return false + } + authAny, ok := otlpMap["auth"] + if !ok { + return false + } + authMap, ok := authAny.(map[string]any) + if !ok { + return false + } + authType, _ := authMap["type"].(string) + return normalizeOTLPAuthType(authType) == "github-oidc" +} + // normalizeOTLPIfMissingMode returns a validated if-missing mode. // Empty string means "unset/default (error)". func normalizeOTLPIfMissingMode(mode string) string { diff --git a/pkg/workflow/observability_otlp_test.go b/pkg/workflow/observability_otlp_test.go index 361fdf88289..e0f5e15baa0 100644 --- a/pkg/workflow/observability_otlp_test.go +++ b/pkg/workflow/observability_otlp_test.go @@ -222,6 +222,72 @@ func TestGetOTLPIfMissingMode(t *testing.T) { }) } +func TestGetOTLPGitHubOIDCAudience(t *testing.T) { + t.Run("returns parsed audience for github-oidc auth", func(t *testing.T) { + got := getOTLPGitHubOIDCAudience(&FrontmatterConfig{ + Observability: &ObservabilityConfig{ + OTLP: &OTLPConfig{ + Auth: &OTLPAuthConfig{ + Type: "github-oidc", + Audience: "https://collector.example.com", + }, + }, + }, + }, nil) + assert.Equal(t, "https://collector.example.com", got) + }) + + t.Run("returns empty for non-github-oidc auth", func(t *testing.T) { + got := getOTLPGitHubOIDCAudience(nil, map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "auth": map[string]any{ + "type": "bearer", + "audience": "https://collector.example.com", + }, + }, + }, + }) + assert.Empty(t, got) + }) + + t.Run("returns raw audience when github-oidc auth is set", func(t *testing.T) { + got := getOTLPGitHubOIDCAudience(nil, map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "auth": map[string]any{ + "type": "github-oidc", + "audience": "api://AzureADTokenExchange", + }, + }, + }, + }) + assert.Equal(t, "api://AzureADTokenExchange", got) + }) +} + +func TestHasOTLPGitHubOIDCAuth(t *testing.T) { + assert.True(t, hasOTLPGitHubOIDCAuth(&FrontmatterConfig{ + Observability: &ObservabilityConfig{ + OTLP: &OTLPConfig{ + Auth: &OTLPAuthConfig{ + Type: "github-oidc", + }, + }, + }, + }, nil)) + + assert.False(t, hasOTLPGitHubOIDCAuth(nil, map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "auth": map[string]any{ + "type": "bearer", + }, + }, + }, + })) +} + // TestInjectOTLPConfig verifies that injectOTLPConfig correctly modifies WorkflowData. func TestInjectOTLPConfig(t *testing.T) { newCompiler := func() *Compiler { return &Compiler{} } diff --git a/pkg/workflow/setup_step_version_test.go b/pkg/workflow/setup_step_version_test.go index e139a936c3b..bc6166344ef 100644 --- a/pkg/workflow/setup_step_version_test.go +++ b/pkg/workflow/setup_step_version_test.go @@ -269,3 +269,63 @@ func TestGenerateSetupStepIncludesEngineIDInScriptModeFromAIField(t *testing.T) t.Fatalf("expected setup script step to include GH_AW_INFO_ENGINE_ID from AI field, got:\n%s", combined) } } + +func TestGenerateSetupStepIncludesOTLPOIDCMintingBeforeSetup(t *testing.T) { + c := NewCompiler() + data := &WorkflowData{ + Name: "my-workflow", + RawFrontmatter: map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "auth": map[string]any{ + "type": "github-oidc", + "audience": "https://example.com/collector", + }, + }, + }, + }, + } + + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") + combined := strings.Join(lines, "") + + if !strings.Contains(combined, "id: mint-otlp-oidc-token") { + t.Fatalf("expected setup step to include OTLP OIDC mint step, got:\n%s", combined) + } + if !strings.Contains(combined, "otlp-oidc-token: ${{ steps.mint-otlp-oidc-token.outputs.token }}") { + t.Fatalf("expected setup action input to include minted OTLP OIDC token, got:\n%s", combined) + } + + mintPos := strings.Index(combined, "id: mint-otlp-oidc-token") + setupPos := strings.Index(combined, "id: setup") + if mintPos < 0 || setupPos < 0 || mintPos > setupPos { + t.Fatalf("expected OTLP OIDC mint step to appear before setup step, got:\n%s", combined) + } +} + +func TestGenerateSetupStepIncludesOTLPOIDCTokenInScriptMode(t *testing.T) { + c := NewCompiler() + c.SetActionMode(ActionModeScript) + data := &WorkflowData{ + Name: "my-workflow", + RawFrontmatter: map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "auth": map[string]any{ + "type": "github-oidc", + }, + }, + }, + }, + } + + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") + combined := strings.Join(lines, "") + + if !strings.Contains(combined, "id: mint-otlp-oidc-token") { + t.Fatalf("expected script mode to include OTLP OIDC mint step, got:\n%s", combined) + } + if !strings.Contains(combined, "INPUT_OTLP_OIDC_TOKEN: ${{ steps.mint-otlp-oidc-token.outputs.token }}") { + t.Fatalf("expected setup.sh env to include minted OTLP OIDC token, got:\n%s", combined) + } +} From e77321de85728444b81c637293577d762c9eb25d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 23:23:09 +0000 Subject: [PATCH 03/26] Format action_setup_otlp after OIDC changes Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/action_setup_otlp.cjs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/actions/setup/js/action_setup_otlp.cjs b/actions/setup/js/action_setup_otlp.cjs index e69a854a1f2..c93c8b5c91f 100644 --- a/actions/setup/js/action_setup_otlp.cjs +++ b/actions/setup/js/action_setup_otlp.cjs @@ -90,9 +90,7 @@ async function run() { if (inputOTLPOIDCToken) { const existingHeaders = process.env.OTEL_EXPORTER_OTLP_HEADERS || ""; const hasAuthorizationHeader = /(^|,)\s*authorization\s*=/i.test(existingHeaders); - const mergedHeaders = hasAuthorizationHeader - ? existingHeaders - : (existingHeaders ? `${existingHeaders},` : "") + "Authorization=Bearer " + inputOTLPOIDCToken; + const mergedHeaders = hasAuthorizationHeader ? existingHeaders : (existingHeaders ? `${existingHeaders},` : "") + "Authorization=Bearer " + inputOTLPOIDCToken; process.env.OTEL_EXPORTER_OTLP_HEADERS = mergedHeaders; writeEnvLine(process.env.GITHUB_ENV, "OTEL_EXPORTER_OTLP_HEADERS", mergedHeaders, "OTEL_EXPORTER_OTLP_HEADERS", "GITHUB_ENV"); From 97c83f7e6120e521a64e3a2211f07e0e1a73785e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 00:56:42 +0000 Subject: [PATCH 04/26] Rename OTLP auth key to github-app Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/syntax.md | 4 ++-- pkg/parser/schemas/main_workflow_schema.json | 4 ++-- pkg/workflow/frontmatter_types.go | 6 +++--- pkg/workflow/observability_otlp.go | 8 ++++---- pkg/workflow/observability_otlp_test.go | 6 +++--- pkg/workflow/setup_step_version_test.go | 4 ++-- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/aw/syntax.md b/.github/aw/syntax.md index d1268a97d60..636077ed570 100644 --- a/.github/aw/syntax.md +++ b/.github/aw/syntax.md @@ -290,7 +290,7 @@ The YAML frontmatter supports these fields: - **`observability:`** - Workflow observability and telemetry configuration (object) - **`otlp:`** - Export OpenTelemetry spans to any OTLP-compatible backend (Honeycomb, Grafana Tempo, Sentry, etc.) (object) - `endpoint:` - OTLP collector endpoint URL. When a static URL is provided, its hostname is added to the AWF firewall allowlist automatically. Supports GitHub Actions expressions. - - `auth:` - Optional runtime auth configuration. + - `github-app:` - Optional runtime auth configuration. - `type:` - `github-oidc` to mint a GitHub Actions OIDC credential before `actions/setup` and use it for OTLP Authorization headers. - `audience:` - Optional OIDC audience passed to `core.getIDToken(audience)`. - `headers:` - Comma-separated `key=value` HTTP headers included in every OTLP export request (e.g. `Authorization=Bearer `). Injected as `OTEL_EXPORTER_OTLP_HEADERS`. Supports GitHub Actions expressions. @@ -300,7 +300,7 @@ The YAML frontmatter supports these fields: observability: otlp: endpoint: ${{ secrets.GH_AW_OTEL_ENDPOINT }} - auth: + github-app: type: github-oidc audience: api://AzureADTokenExchange headers: ${{ secrets.GH_AW_OTEL_HEADERS }} diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 97c53e409b6..70c4962cddf 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9669,7 +9669,7 @@ "default": "error", "description": "How to handle missing OTLP endpoint/header values at runtime (for example from unset secrets). 'error' fails workflow startup (default), 'warn' logs a warning and skips MCP gateway OTLP configuration, and 'ignore' skips MCP gateway OTLP configuration without warning. This affects MCP gateway setup only; workflow-level OTEL_* environment variables are still injected." }, - "auth": { + "github-app": { "type": "object", "description": "Optional runtime authentication for OTLP export.", "properties": { @@ -9680,7 +9680,7 @@ }, "audience": { "type": "string", - "description": "Optional OIDC audience passed to core.getIDToken(audience) when auth.type is github-oidc." + "description": "Optional OIDC audience passed to core.getIDToken(audience) when github-app.type is github-oidc." } }, "required": ["type"], diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 77cc2ad2c96..5c9a56dcbc0 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -259,15 +259,15 @@ type OTLPConfig struct { // user.id: "{{ github.actor }}" Attributes map[string]string `json:"attributes,omitempty"` - // Auth configures runtime OTLP authentication. + // Auth configures runtime OTLP authentication via the `github-app` key. // Supported values: - // auth: + // github-app: // type: github-oidc // audience: "api://AzureADTokenExchange" # optional // // When configured, gh-aw mints an OIDC token before actions/setup and passes // it to setup so OTLP requests can include an Authorization bearer token. - Auth *OTLPAuthConfig `json:"auth,omitempty"` + Auth *OTLPAuthConfig `json:"github-app,omitempty"` } // ObservabilityConfig represents workflow observability options. diff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go index b4e799fe1c0..6e43859585a 100644 --- a/pkg/workflow/observability_otlp.go +++ b/pkg/workflow/observability_otlp.go @@ -172,8 +172,8 @@ func normalizeOTLPAuthType(value string) string { } } -// getOTLPGitHubOIDCAudience returns observability.otlp.auth.audience when -// auth.type is configured as github-oidc. Returns empty string when auth is +// getOTLPGitHubOIDCAudience returns observability.otlp.github-app.audience when +// github-app.type is configured as github-oidc. Returns empty string when github-app is // unset, invalid, or not github-oidc. func getOTLPGitHubOIDCAudience(config *FrontmatterConfig, frontmatter map[string]any) string { if config != nil && config.Observability != nil && config.Observability.OTLP != nil && config.Observability.OTLP.Auth != nil { @@ -201,7 +201,7 @@ func getOTLPGitHubOIDCAudience(config *FrontmatterConfig, frontmatter map[string if !ok { return "" } - authAny, ok := otlpMap["auth"] + authAny, ok := otlpMap["github-app"] if !ok { return "" } @@ -242,7 +242,7 @@ func hasOTLPGitHubOIDCAuth(config *FrontmatterConfig, frontmatter map[string]any if !ok { return false } - authAny, ok := otlpMap["auth"] + authAny, ok := otlpMap["github-app"] if !ok { return false } diff --git a/pkg/workflow/observability_otlp_test.go b/pkg/workflow/observability_otlp_test.go index e0f5e15baa0..0fe3d48a8f2 100644 --- a/pkg/workflow/observability_otlp_test.go +++ b/pkg/workflow/observability_otlp_test.go @@ -241,7 +241,7 @@ func TestGetOTLPGitHubOIDCAudience(t *testing.T) { got := getOTLPGitHubOIDCAudience(nil, map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ - "auth": map[string]any{ + "github-app": map[string]any{ "type": "bearer", "audience": "https://collector.example.com", }, @@ -255,7 +255,7 @@ func TestGetOTLPGitHubOIDCAudience(t *testing.T) { got := getOTLPGitHubOIDCAudience(nil, map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ - "auth": map[string]any{ + "github-app": map[string]any{ "type": "github-oidc", "audience": "api://AzureADTokenExchange", }, @@ -280,7 +280,7 @@ func TestHasOTLPGitHubOIDCAuth(t *testing.T) { assert.False(t, hasOTLPGitHubOIDCAuth(nil, map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ - "auth": map[string]any{ + "github-app": map[string]any{ "type": "bearer", }, }, diff --git a/pkg/workflow/setup_step_version_test.go b/pkg/workflow/setup_step_version_test.go index bc6166344ef..34d8a6b20f6 100644 --- a/pkg/workflow/setup_step_version_test.go +++ b/pkg/workflow/setup_step_version_test.go @@ -277,7 +277,7 @@ func TestGenerateSetupStepIncludesOTLPOIDCMintingBeforeSetup(t *testing.T) { RawFrontmatter: map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ - "auth": map[string]any{ + "github-app": map[string]any{ "type": "github-oidc", "audience": "https://example.com/collector", }, @@ -311,7 +311,7 @@ func TestGenerateSetupStepIncludesOTLPOIDCTokenInScriptMode(t *testing.T) { RawFrontmatter: map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ - "auth": map[string]any{ + "github-app": map[string]any{ "type": "github-oidc", }, }, From 4d286c77097eb8a032b3efea4eec25a26a10e946 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 00:58:49 +0000 Subject: [PATCH 05/26] Align OTLP typed field with github-app key Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/frontmatter_types.go | 4 ++-- pkg/workflow/observability_otlp.go | 10 +++++----- pkg/workflow/observability_otlp_test.go | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 5c9a56dcbc0..15a3253d089 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -259,7 +259,7 @@ type OTLPConfig struct { // user.id: "{{ github.actor }}" Attributes map[string]string `json:"attributes,omitempty"` - // Auth configures runtime OTLP authentication via the `github-app` key. + // GitHubApp configures runtime OTLP authentication via the `github-app` key. // Supported values: // github-app: // type: github-oidc @@ -267,7 +267,7 @@ type OTLPConfig struct { // // When configured, gh-aw mints an OIDC token before actions/setup and passes // it to setup so OTLP requests can include an Authorization bearer token. - Auth *OTLPAuthConfig `json:"github-app,omitempty"` + GitHubApp *OTLPAuthConfig `json:"github-app,omitempty"` } // ObservabilityConfig represents workflow observability options. diff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go index 6e43859585a..9d617b27d53 100644 --- a/pkg/workflow/observability_otlp.go +++ b/pkg/workflow/observability_otlp.go @@ -176,9 +176,9 @@ func normalizeOTLPAuthType(value string) string { // github-app.type is configured as github-oidc. Returns empty string when github-app is // unset, invalid, or not github-oidc. func getOTLPGitHubOIDCAudience(config *FrontmatterConfig, frontmatter map[string]any) string { - if config != nil && config.Observability != nil && config.Observability.OTLP != nil && config.Observability.OTLP.Auth != nil { - if normalizeOTLPAuthType(config.Observability.OTLP.Auth.Type) == "github-oidc" { - return strings.TrimSpace(config.Observability.OTLP.Auth.Audience) + if config != nil && config.Observability != nil && config.Observability.OTLP != nil && config.Observability.OTLP.GitHubApp != nil { + if normalizeOTLPAuthType(config.Observability.OTLP.GitHubApp.Type) == "github-oidc" { + return strings.TrimSpace(config.Observability.OTLP.GitHubApp.Audience) } } @@ -218,8 +218,8 @@ func getOTLPGitHubOIDCAudience(config *FrontmatterConfig, frontmatter map[string } func hasOTLPGitHubOIDCAuth(config *FrontmatterConfig, frontmatter map[string]any) bool { - if config != nil && config.Observability != nil && config.Observability.OTLP != nil && config.Observability.OTLP.Auth != nil { - if normalizeOTLPAuthType(config.Observability.OTLP.Auth.Type) == "github-oidc" { + if config != nil && config.Observability != nil && config.Observability.OTLP != nil && config.Observability.OTLP.GitHubApp != nil { + if normalizeOTLPAuthType(config.Observability.OTLP.GitHubApp.Type) == "github-oidc" { return true } } diff --git a/pkg/workflow/observability_otlp_test.go b/pkg/workflow/observability_otlp_test.go index 0fe3d48a8f2..55b89597ba5 100644 --- a/pkg/workflow/observability_otlp_test.go +++ b/pkg/workflow/observability_otlp_test.go @@ -227,7 +227,7 @@ func TestGetOTLPGitHubOIDCAudience(t *testing.T) { got := getOTLPGitHubOIDCAudience(&FrontmatterConfig{ Observability: &ObservabilityConfig{ OTLP: &OTLPConfig{ - Auth: &OTLPAuthConfig{ + GitHubApp: &OTLPAuthConfig{ Type: "github-oidc", Audience: "https://collector.example.com", }, @@ -270,7 +270,7 @@ func TestHasOTLPGitHubOIDCAuth(t *testing.T) { assert.True(t, hasOTLPGitHubOIDCAuth(&FrontmatterConfig{ Observability: &ObservabilityConfig{ OTLP: &OTLPConfig{ - Auth: &OTLPAuthConfig{ + GitHubApp: &OTLPAuthConfig{ Type: "github-oidc", }, }, From f2b547b1c6639c3bc300fa400518eebb6076ecd6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 05:05:34 +0000 Subject: [PATCH 06/26] Fix OTLP OIDC header propagation and permission validation Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/syntax.md | 1 + actions/setup/js/action_setup_otlp.cjs | 51 ++++++++++++++++++- actions/setup/js/action_setup_otlp.test.cjs | 20 ++++++++ pkg/workflow/compiler_validators_test.go | 41 +++++++++++++++ .../permissions_compiler_validator.go | 27 +++++++--- 5 files changed, 131 insertions(+), 9 deletions(-) diff --git a/.github/aw/syntax.md b/.github/aw/syntax.md index 636077ed570..237e60a1195 100644 --- a/.github/aw/syntax.md +++ b/.github/aw/syntax.md @@ -293,6 +293,7 @@ The YAML frontmatter supports these fields: - `github-app:` - Optional runtime auth configuration. - `type:` - `github-oidc` to mint a GitHub Actions OIDC credential before `actions/setup` and use it for OTLP Authorization headers. - `audience:` - Optional OIDC audience passed to `core.getIDToken(audience)`. + - Requires `permissions.id-token: write` on the workflow/job. - `headers:` - Comma-separated `key=value` HTTP headers included in every OTLP export request (e.g. `Authorization=Bearer `). Injected as `OTEL_EXPORTER_OTLP_HEADERS`. Supports GitHub Actions expressions. - Example: diff --git a/actions/setup/js/action_setup_otlp.cjs b/actions/setup/js/action_setup_otlp.cjs index c93c8b5c91f..f9c5e2c63f6 100644 --- a/actions/setup/js/action_setup_otlp.cjs +++ b/actions/setup/js/action_setup_otlp.cjs @@ -44,6 +44,47 @@ function writeEnvLine(filePath, key, value, logLabel, fileLabel) { console.log(`[otlp] ${logLabel} written to ${fileLabel}`); } +/** + * @param {string} headers + * @returns {boolean} + */ +function hasAuthorizationHeader(headers) { + return /(^|,)\s*authorization\s*=/i.test(headers); +} + +/** + * @param {string} headers + * @param {string} token + * @returns {string} + */ +function mergeAuthorizationHeader(headers, token) { + if (hasAuthorizationHeader(headers)) return headers; + return (headers ? `${headers},` : "") + "Authorization=Bearer " + token; +} + +/** + * @param {string} endpointsRaw + * @param {string} token + * @returns {string} + */ +function mergeAuthorizationIntoOTLPEndpoints(endpointsRaw, token) { + if (!endpointsRaw) return endpointsRaw; + let parsed; + try { + parsed = JSON.parse(endpointsRaw); + } catch { + return endpointsRaw; + } + if (!Array.isArray(parsed)) return endpointsRaw; + const updated = parsed.map(entry => { + if (!entry || typeof entry !== "object") return entry; + const currentHeaders = typeof entry.headers === "string" ? entry.headers : ""; + const mergedHeaders = mergeAuthorizationHeader(currentHeaders, token); + return mergedHeaders ? { ...entry, headers: mergedHeaders } : entry; + }); + return JSON.stringify(updated); +} + /** * Send the OTLP job-setup span and propagate trace context via GITHUB_OUTPUT / * GITHUB_ENV. Non-fatal: all errors are silently swallowed. @@ -89,11 +130,17 @@ async function run() { const inputOTLPOIDCToken = getActionInput("OTLP_OIDC_TOKEN"); if (inputOTLPOIDCToken) { const existingHeaders = process.env.OTEL_EXPORTER_OTLP_HEADERS || ""; - const hasAuthorizationHeader = /(^|,)\s*authorization\s*=/i.test(existingHeaders); - const mergedHeaders = hasAuthorizationHeader ? existingHeaders : (existingHeaders ? `${existingHeaders},` : "") + "Authorization=Bearer " + inputOTLPOIDCToken; + const mergedHeaders = mergeAuthorizationHeader(existingHeaders, inputOTLPOIDCToken); process.env.OTEL_EXPORTER_OTLP_HEADERS = mergedHeaders; writeEnvLine(process.env.GITHUB_ENV, "OTEL_EXPORTER_OTLP_HEADERS", mergedHeaders, "OTEL_EXPORTER_OTLP_HEADERS", "GITHUB_ENV"); + + const existingEndpoints = process.env.GH_AW_OTLP_ENDPOINTS || ""; + const mergedEndpoints = mergeAuthorizationIntoOTLPEndpoints(existingEndpoints, inputOTLPOIDCToken); + if (mergedEndpoints && mergedEndpoints !== existingEndpoints) { + process.env.GH_AW_OTLP_ENDPOINTS = mergedEndpoints; + writeEnvLine(process.env.GITHUB_ENV, "GH_AW_OTLP_ENDPOINTS", mergedEndpoints, "GH_AW_OTLP_ENDPOINTS", "GITHUB_ENV"); + } } if (!endpoints) { diff --git a/actions/setup/js/action_setup_otlp.test.cjs b/actions/setup/js/action_setup_otlp.test.cjs index 4ec96caf4f7..aaaa50a4ffd 100644 --- a/actions/setup/js/action_setup_otlp.test.cjs +++ b/actions/setup/js/action_setup_otlp.test.cjs @@ -64,6 +64,7 @@ describe("action_setup_otlp.cjs", () => { INPUT_PARENT_SPAN_ID: process.env.INPUT_PARENT_SPAN_ID, "INPUT_PARENT-SPAN-ID": process.env["INPUT_PARENT-SPAN-ID"], INPUT_OTLP_OIDC_TOKEN: process.env.INPUT_OTLP_OIDC_TOKEN, + GH_AW_OTLP_ENDPOINTS: process.env.GH_AW_OTLP_ENDPOINTS, OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, }; @@ -172,6 +173,25 @@ describe("action_setup_otlp.cjs", () => { expect(process.env.OTEL_EXPORTER_OTLP_HEADERS).toBe("Authorization=******"); }); + + it("merges Authorization into each GH_AW_OTLP_ENDPOINTS endpoint and exports it", async () => { + const minted = "oidc" + "-endpoint" + "-value"; + process.env.INPUT_OTLP_OIDC_TOKEN = minted; + process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://otlp-a.example.com", headers: "X-Tenant=acme" }, { url: "https://otlp-b.example.com" }, { url: "https://otlp-c.example.com", headers: "Authorization=******" }]); + + await run(); + + const endpoints = JSON.parse(process.env.GH_AW_OTLP_ENDPOINTS || "[]"); + expect(endpoints[0].headers).toContain("X-Tenant=acme"); + expect(endpoints[0].headers).toContain("Authorization="); + expect(endpoints[0].headers).toContain(minted); + expect(endpoints[1].headers).toContain("Authorization="); + expect(endpoints[1].headers).toContain(minted); + expect(endpoints[2].headers).toBe("Authorization=******"); + expect(readFileSync(envFile, "utf8")).toContain("GH_AW_OTLP_ENDPOINTS="); + expect(readFileSync(envFile, "utf8")).toContain("Authorization="); + expect(readFileSync(envFile, "utf8")).toContain(minted); + }); }); describe("SETUP_START_MS propagation", () => { diff --git a/pkg/workflow/compiler_validators_test.go b/pkg/workflow/compiler_validators_test.go index 82c4e531cfb..31792e98e67 100644 --- a/pkg/workflow/compiler_validators_test.go +++ b/pkg/workflow/compiler_validators_test.go @@ -213,6 +213,47 @@ func TestValidatePermissions(t *testing.T) { shouldError: false, wantPermissions: true, }, + { + name: "observability otlp github-app github-oidc requires id-token write", + workflowData: &WorkflowData{ + Name: "Test", + MarkdownContent: "# Test", + AI: "copilot", + Permissions: "permissions:\n contents: read\n", + RawFrontmatter: map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{ + "type": "github-oidc", + }, + }, + }, + }, + }, + shouldError: true, + errorContains: "observability.otlp.github-app.type: github-oidc requires permissions.id-token: write", + wantPermissions: false, + }, + { + name: "observability otlp github-app github-oidc with id-token write succeeds", + workflowData: &WorkflowData{ + Name: "Test", + MarkdownContent: "# Test", + AI: "copilot", + Permissions: "permissions:\n contents: read\n id-token: write\n", + RawFrontmatter: map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{ + "type": "github-oidc", + }, + }, + }, + }, + }, + shouldError: false, + wantPermissions: true, + }, } for _, tt := range tests { diff --git a/pkg/workflow/permissions_compiler_validator.go b/pkg/workflow/permissions_compiler_validator.go index 6900b2f23a4..13cf08c735c 100644 --- a/pkg/workflow/permissions_compiler_validator.go +++ b/pkg/workflow/permissions_compiler_validator.go @@ -141,8 +141,8 @@ func (c *Compiler) validatePermissions(workflowData *WorkflowData, markdownPath } } - // Enforce required id-token: write permission for engine.auth.type=github-oidc. - if err := validateEngineAuthPermissions(workflowData, workflowPermissions); err != nil { + // Enforce required id-token: write permission for OIDC auth users. + if err := validateOIDCPermissions(workflowData, workflowPermissions); err != nil { return nil, formatCompilerError(markdownPath, "error", err.Error(), err) } @@ -159,21 +159,34 @@ Ensure proper audience validation and trust policies are configured.` return workflowPermissions, nil } -func validateEngineAuthPermissions(workflowData *WorkflowData, workflowPermissions *Permissions) error { - if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.Auth == nil { +func validateOIDCPermissions(workflowData *WorkflowData, workflowPermissions *Permissions) error { + if workflowData == nil { return nil } - if workflowData.EngineConfig.Auth.Type != "github-oidc" { + requiresIDTokenWrite := false + errorPrefix := "" + + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Auth != nil && workflowData.EngineConfig.Auth.Type == "github-oidc" { + requiresIDTokenWrite = true + errorPrefix = "engine.auth.type: github-oidc" + } + + if !requiresIDTokenWrite && hasOTLPGitHubOIDCAuth(workflowData.ParsedFrontmatter, workflowData.RawFrontmatter) { + requiresIDTokenWrite = true + errorPrefix = "observability.otlp.github-app.type: github-oidc" + } + + if !requiresIDTokenWrite { return nil } if workflowPermissions == nil { - return errors.New("engine.auth.type: github-oidc requires permissions.id-token: write") + return errors.New(errorPrefix + " requires permissions.id-token: write") } if level, exists := workflowPermissions.Get(PermissionIdToken); !exists || level != PermissionWrite { - return errors.New("engine.auth.type: github-oidc requires permissions.id-token: write") + return errors.New(errorPrefix + " requires permissions.id-token: write") } return nil From 33994c68fdc4c8e02879fe21b0f02aa9bccfe9ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 05:07:11 +0000 Subject: [PATCH 07/26] Refine OTLP endpoint header merge helper Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/action_setup_otlp.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/setup/js/action_setup_otlp.cjs b/actions/setup/js/action_setup_otlp.cjs index f9c5e2c63f6..04c80d3bf5d 100644 --- a/actions/setup/js/action_setup_otlp.cjs +++ b/actions/setup/js/action_setup_otlp.cjs @@ -80,7 +80,7 @@ function mergeAuthorizationIntoOTLPEndpoints(endpointsRaw, token) { if (!entry || typeof entry !== "object") return entry; const currentHeaders = typeof entry.headers === "string" ? entry.headers : ""; const mergedHeaders = mergeAuthorizationHeader(currentHeaders, token); - return mergedHeaders ? { ...entry, headers: mergedHeaders } : entry; + return { ...entry, headers: mergedHeaders }; }); return JSON.stringify(updated); } From fc85da64644eb8e728bcb26775368c9f9d8c0066 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 10:22:47 +0000 Subject: [PATCH 08/26] Simplify OTLP github-app OIDC frontmatter Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/syntax.md | 3 +- pkg/workflow/compiler_validators_test.go | 14 ++- pkg/workflow/frontmatter_types.go | 11 +-- pkg/workflow/observability_otlp.go | 85 +++++-------------- pkg/workflow/observability_otlp_test.go | 21 ++--- .../permissions_compiler_validator.go | 2 +- pkg/workflow/setup_step_version_test.go | 2 - 7 files changed, 40 insertions(+), 98 deletions(-) diff --git a/.github/aw/syntax.md b/.github/aw/syntax.md index 237e60a1195..3d661bb4a0d 100644 --- a/.github/aw/syntax.md +++ b/.github/aw/syntax.md @@ -291,7 +291,7 @@ The YAML frontmatter supports these fields: - **`otlp:`** - Export OpenTelemetry spans to any OTLP-compatible backend (Honeycomb, Grafana Tempo, Sentry, etc.) (object) - `endpoint:` - OTLP collector endpoint URL. When a static URL is provided, its hostname is added to the AWF firewall allowlist automatically. Supports GitHub Actions expressions. - `github-app:` - Optional runtime auth configuration. - - `type:` - `github-oidc` to mint a GitHub Actions OIDC credential before `actions/setup` and use it for OTLP Authorization headers. + - When configured, gh-aw mints a GitHub Actions OIDC credential before `actions/setup` and uses it for OTLP Authorization headers. - `audience:` - Optional OIDC audience passed to `core.getIDToken(audience)`. - Requires `permissions.id-token: write` on the workflow/job. - `headers:` - Comma-separated `key=value` HTTP headers included in every OTLP export request (e.g. `Authorization=Bearer `). Injected as `OTEL_EXPORTER_OTLP_HEADERS`. Supports GitHub Actions expressions. @@ -302,7 +302,6 @@ The YAML frontmatter supports these fields: otlp: endpoint: ${{ secrets.GH_AW_OTEL_ENDPOINT }} github-app: - type: github-oidc audience: api://AzureADTokenExchange headers: ${{ secrets.GH_AW_OTEL_HEADERS }} ``` diff --git a/pkg/workflow/compiler_validators_test.go b/pkg/workflow/compiler_validators_test.go index 31792e98e67..fee9115b170 100644 --- a/pkg/workflow/compiler_validators_test.go +++ b/pkg/workflow/compiler_validators_test.go @@ -214,7 +214,7 @@ func TestValidatePermissions(t *testing.T) { wantPermissions: true, }, { - name: "observability otlp github-app github-oidc requires id-token write", + name: "observability otlp github-app requires id-token write", workflowData: &WorkflowData{ Name: "Test", MarkdownContent: "# Test", @@ -223,19 +223,17 @@ func TestValidatePermissions(t *testing.T) { RawFrontmatter: map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ - "github-app": map[string]any{ - "type": "github-oidc", - }, + "github-app": map[string]any{}, }, }, }, }, shouldError: true, - errorContains: "observability.otlp.github-app.type: github-oidc requires permissions.id-token: write", + errorContains: "observability.otlp.github-app requires permissions.id-token: write", wantPermissions: false, }, { - name: "observability otlp github-app github-oidc with id-token write succeeds", + name: "observability otlp github-app with id-token write succeeds", workflowData: &WorkflowData{ Name: "Test", MarkdownContent: "# Test", @@ -244,9 +242,7 @@ func TestValidatePermissions(t *testing.T) { RawFrontmatter: map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ - "github-app": map[string]any{ - "type": "github-oidc", - }, + "github-app": map[string]any{}, }, }, }, diff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go index 15a3253d089..92fe12a603f 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -205,11 +205,9 @@ type OTLPEndpointConfig struct { Headers any `json:"headers,omitempty"` } -// OTLPAuthConfig holds optional runtime auth configuration for OTLP export. -// Currently supports GitHub Actions OIDC bearer token minting. -type OTLPAuthConfig struct { - // Type is the auth mechanism. Supported value: "github-oidc". - Type string `json:"type,omitempty"` +// OTLPGitHubAppConfig holds optional runtime GitHub app auth configuration for OTLP export. +// GitHub Actions OIDC token minting is implied when this block is present. +type OTLPGitHubAppConfig struct { // Audience is an optional OIDC audience passed to core.getIDToken(audience). Audience string `json:"audience,omitempty"` } @@ -262,12 +260,11 @@ type OTLPConfig struct { // GitHubApp configures runtime OTLP authentication via the `github-app` key. // Supported values: // github-app: - // type: github-oidc // audience: "api://AzureADTokenExchange" # optional // // When configured, gh-aw mints an OIDC token before actions/setup and passes // it to setup so OTLP requests can include an Authorization bearer token. - GitHubApp *OTLPAuthConfig `json:"github-app,omitempty"` + GitHubApp *OTLPGitHubAppConfig `json:"github-app,omitempty"` } // ObservabilityConfig represents workflow observability options. diff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go index 9d617b27d53..8172adad9b7 100644 --- a/pkg/workflow/observability_otlp.go +++ b/pkg/workflow/observability_otlp.go @@ -163,95 +163,56 @@ func getOTLPEndpointEnvValue(config *FrontmatterConfig) string { return "" } -func normalizeOTLPAuthType(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "github-oidc": - return "github-oidc" - default: - return "" - } -} - -// getOTLPGitHubOIDCAudience returns observability.otlp.github-app.audience when -// github-app.type is configured as github-oidc. Returns empty string when github-app is -// unset, invalid, or not github-oidc. -func getOTLPGitHubOIDCAudience(config *FrontmatterConfig, frontmatter map[string]any) string { +func getOTLPGitHubApp(config *FrontmatterConfig, frontmatter map[string]any) *OTLPGitHubAppConfig { if config != nil && config.Observability != nil && config.Observability.OTLP != nil && config.Observability.OTLP.GitHubApp != nil { - if normalizeOTLPAuthType(config.Observability.OTLP.GitHubApp.Type) == "github-oidc" { - return strings.TrimSpace(config.Observability.OTLP.GitHubApp.Audience) - } + return config.Observability.OTLP.GitHubApp } - if frontmatter == nil { - return "" + return nil } obsAny, ok := frontmatter["observability"] if !ok { - return "" + return nil } obsMap, ok := obsAny.(map[string]any) if !ok { - return "" + return nil } otlpAny, ok := obsMap["otlp"] if !ok { - return "" + return nil } otlpMap, ok := otlpAny.(map[string]any) if !ok { - return "" + return nil } authAny, ok := otlpMap["github-app"] if !ok { - return "" + return nil } authMap, ok := authAny.(map[string]any) if !ok { - return "" + return nil } - authType, _ := authMap["type"].(string) - if normalizeOTLPAuthType(authType) != "github-oidc" { + audience, _ := authMap["audience"].(string) + return &OTLPGitHubAppConfig{ + Audience: audience, + } +} + +// getOTLPGitHubOIDCAudience returns observability.otlp.github-app.audience. +// Returns empty string when github-app is unset or invalid. +func getOTLPGitHubOIDCAudience(config *FrontmatterConfig, frontmatter map[string]any) string { + githubApp := getOTLPGitHubApp(config, frontmatter) + if githubApp == nil { return "" } - audience, _ := authMap["audience"].(string) - return strings.TrimSpace(audience) + + return strings.TrimSpace(githubApp.Audience) } func hasOTLPGitHubOIDCAuth(config *FrontmatterConfig, frontmatter map[string]any) bool { - if config != nil && config.Observability != nil && config.Observability.OTLP != nil && config.Observability.OTLP.GitHubApp != nil { - if normalizeOTLPAuthType(config.Observability.OTLP.GitHubApp.Type) == "github-oidc" { - return true - } - } - if frontmatter == nil { - return false - } - obsAny, ok := frontmatter["observability"] - if !ok { - return false - } - obsMap, ok := obsAny.(map[string]any) - if !ok { - return false - } - otlpAny, ok := obsMap["otlp"] - if !ok { - return false - } - otlpMap, ok := otlpAny.(map[string]any) - if !ok { - return false - } - authAny, ok := otlpMap["github-app"] - if !ok { - return false - } - authMap, ok := authAny.(map[string]any) - if !ok { - return false - } - authType, _ := authMap["type"].(string) - return normalizeOTLPAuthType(authType) == "github-oidc" + return getOTLPGitHubApp(config, frontmatter) != nil } // normalizeOTLPIfMissingMode returns a validated if-missing mode. diff --git a/pkg/workflow/observability_otlp_test.go b/pkg/workflow/observability_otlp_test.go index 55b89597ba5..5cc1d57e4ff 100644 --- a/pkg/workflow/observability_otlp_test.go +++ b/pkg/workflow/observability_otlp_test.go @@ -223,12 +223,11 @@ func TestGetOTLPIfMissingMode(t *testing.T) { } func TestGetOTLPGitHubOIDCAudience(t *testing.T) { - t.Run("returns parsed audience for github-oidc auth", func(t *testing.T) { + t.Run("returns parsed audience when github-app is configured", func(t *testing.T) { got := getOTLPGitHubOIDCAudience(&FrontmatterConfig{ Observability: &ObservabilityConfig{ OTLP: &OTLPConfig{ - GitHubApp: &OTLPAuthConfig{ - Type: "github-oidc", + GitHubApp: &OTLPGitHubAppConfig{ Audience: "https://collector.example.com", }, }, @@ -237,26 +236,21 @@ func TestGetOTLPGitHubOIDCAudience(t *testing.T) { assert.Equal(t, "https://collector.example.com", got) }) - t.Run("returns empty for non-github-oidc auth", func(t *testing.T) { + t.Run("returns empty when github-app is missing", func(t *testing.T) { got := getOTLPGitHubOIDCAudience(nil, map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ - "github-app": map[string]any{ - "type": "bearer", - "audience": "https://collector.example.com", - }, }, }, }) assert.Empty(t, got) }) - t.Run("returns raw audience when github-oidc auth is set", func(t *testing.T) { + t.Run("returns raw audience when github-app is set", func(t *testing.T) { got := getOTLPGitHubOIDCAudience(nil, map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ "github-app": map[string]any{ - "type": "github-oidc", "audience": "api://AzureADTokenExchange", }, }, @@ -270,18 +264,15 @@ func TestHasOTLPGitHubOIDCAuth(t *testing.T) { assert.True(t, hasOTLPGitHubOIDCAuth(&FrontmatterConfig{ Observability: &ObservabilityConfig{ OTLP: &OTLPConfig{ - GitHubApp: &OTLPAuthConfig{ - Type: "github-oidc", - }, + GitHubApp: &OTLPGitHubAppConfig{}, }, }, }, nil)) - assert.False(t, hasOTLPGitHubOIDCAuth(nil, map[string]any{ + assert.True(t, hasOTLPGitHubOIDCAuth(nil, map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ "github-app": map[string]any{ - "type": "bearer", }, }, }, diff --git a/pkg/workflow/permissions_compiler_validator.go b/pkg/workflow/permissions_compiler_validator.go index 13cf08c735c..bb863fbae9f 100644 --- a/pkg/workflow/permissions_compiler_validator.go +++ b/pkg/workflow/permissions_compiler_validator.go @@ -174,7 +174,7 @@ func validateOIDCPermissions(workflowData *WorkflowData, workflowPermissions *Pe if !requiresIDTokenWrite && hasOTLPGitHubOIDCAuth(workflowData.ParsedFrontmatter, workflowData.RawFrontmatter) { requiresIDTokenWrite = true - errorPrefix = "observability.otlp.github-app.type: github-oidc" + errorPrefix = "observability.otlp.github-app" } if !requiresIDTokenWrite { diff --git a/pkg/workflow/setup_step_version_test.go b/pkg/workflow/setup_step_version_test.go index 34d8a6b20f6..5bb1ee51855 100644 --- a/pkg/workflow/setup_step_version_test.go +++ b/pkg/workflow/setup_step_version_test.go @@ -278,7 +278,6 @@ func TestGenerateSetupStepIncludesOTLPOIDCMintingBeforeSetup(t *testing.T) { "observability": map[string]any{ "otlp": map[string]any{ "github-app": map[string]any{ - "type": "github-oidc", "audience": "https://example.com/collector", }, }, @@ -312,7 +311,6 @@ func TestGenerateSetupStepIncludesOTLPOIDCTokenInScriptMode(t *testing.T) { "observability": map[string]any{ "otlp": map[string]any{ "github-app": map[string]any{ - "type": "github-oidc", }, }, }, From 0dbd3c745750676f1c565a8f7d4dbb9751417109 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 10:25:29 +0000 Subject: [PATCH 09/26] Add OTLP github-app helper coverage Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/observability_otlp_test.go | 63 +++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/pkg/workflow/observability_otlp_test.go b/pkg/workflow/observability_otlp_test.go index 5cc1d57e4ff..9bf6c1c65eb 100644 --- a/pkg/workflow/observability_otlp_test.go +++ b/pkg/workflow/observability_otlp_test.go @@ -260,6 +260,63 @@ func TestGetOTLPGitHubOIDCAudience(t *testing.T) { }) } +func TestGetOTLPGitHubApp(t *testing.T) { + t.Run("returns parsed github-app config", func(t *testing.T) { + got := getOTLPGitHubApp(&FrontmatterConfig{ + Observability: &ObservabilityConfig{ + OTLP: &OTLPConfig{ + GitHubApp: &OTLPGitHubAppConfig{ + Audience: "https://collector.example.com", + }, + }, + }, + }, nil) + require.NotNil(t, got) + assert.Equal(t, "https://collector.example.com", got.Audience) + }) + + t.Run("returns raw github-app config", func(t *testing.T) { + got := getOTLPGitHubApp(nil, map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{ + "audience": "api://AzureADTokenExchange", + }, + }, + }, + }) + require.NotNil(t, got) + assert.Equal(t, "api://AzureADTokenExchange", got.Audience) + }) + + t.Run("returns nil when github-app is missing", func(t *testing.T) { + got := getOTLPGitHubApp(nil, map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{}, + }, + }) + assert.Nil(t, got) + }) + + t.Run("returns nil for invalid raw structure", func(t *testing.T) { + assert.Nil(t, getOTLPGitHubApp(nil, map[string]any{ + "observability": "invalid", + })) + assert.Nil(t, getOTLPGitHubApp(nil, map[string]any{ + "observability": map[string]any{ + "otlp": "invalid", + }, + })) + assert.Nil(t, getOTLPGitHubApp(nil, map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": "invalid", + }, + }, + })) + }) +} + func TestHasOTLPGitHubOIDCAuth(t *testing.T) { assert.True(t, hasOTLPGitHubOIDCAuth(&FrontmatterConfig{ Observability: &ObservabilityConfig{ @@ -277,6 +334,12 @@ func TestHasOTLPGitHubOIDCAuth(t *testing.T) { }, }, })) + + assert.False(t, hasOTLPGitHubOIDCAuth(nil, map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{}, + }, + })) } // TestInjectOTLPConfig verifies that injectOTLPConfig correctly modifies WorkflowData. From 018bc02c739de3e2a405e730a9b480a3779493b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 10:46:07 +0000 Subject: [PATCH 10/26] Preserve OTLP github-app info during observability merge Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../compiler_orchestrator_workflow.go | 10 ++++++ pkg/workflow/compiler_yaml_step_generation.go | 9 +++-- pkg/workflow/observability_otlp.go | 27 ++++++++++++++ pkg/workflow/observability_otlp_test.go | 36 ++++++++++++++++--- pkg/workflow/setup_step_version_test.go | 29 +++++++++++++-- 5 files changed, 103 insertions(+), 8 deletions(-) diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index a7e9d01625c..b10ce10778d 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -229,6 +229,16 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) if len(mergedAttrs) > 0 { newOTLP["attributes"] = mergedAttrs } + // Preserve OTLP github-app auth config so the compiler can emit the + // pre-setup OIDC mint step and validate id-token permissions. + // Main workflow takes precedence over imported defaults. + githubApp := extractRawOTLPGitHubAppMap(mainObs) + if githubApp == nil { + githubApp = extractRawOTLPGitHubAppMap(importedObs) + } + if githubApp != nil { + newOTLP["github-app"] = githubApp + } workflowData.RawFrontmatter["observability"] = map[string]any{ "otlp": newOTLP, } diff --git a/pkg/workflow/compiler_yaml_step_generation.go b/pkg/workflow/compiler_yaml_step_generation.go index de25db1fd8c..a7f6f726631 100644 --- a/pkg/workflow/compiler_yaml_step_generation.go +++ b/pkg/workflow/compiler_yaml_step_generation.go @@ -129,7 +129,12 @@ func setupParentSpanNeedsExpr(upstreamJob constants.JobName) string { } func (c *Compiler) generateOTLPOIDCMintStep(data *WorkflowData) []string { - if data == nil || !hasOTLPGitHubOIDCAuth(data.ParsedFrontmatter, data.RawFrontmatter) { + if data == nil { + return nil + } + + githubApp := getOTLPGitHubApp(data.ParsedFrontmatter, data.RawFrontmatter) + if githubApp == nil { return nil } @@ -146,7 +151,7 @@ func (c *Compiler) generateOTLPOIDCMintStep(data *WorkflowData) []string { " core.setOutput('token', token);\n", } - if audience := getOTLPGitHubOIDCAudience(data.ParsedFrontmatter, data.RawFrontmatter); audience != "" { + if audience := strings.TrimSpace(githubApp.Audience); audience != "" { lines = append(lines, " env:\n") lines = append(lines, formatYAMLEnv(" ", "GH_AW_OTLP_OIDC_AUDIENCE", audience)) } diff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go index 8172adad9b7..3e869dc1683 100644 --- a/pkg/workflow/observability_otlp.go +++ b/pkg/workflow/observability_otlp.go @@ -561,6 +561,33 @@ func extractRawOTLPEndpointMaps(obs map[string]any) []map[string]any { return result } +// extractRawOTLPGitHubAppMap returns observability.otlp.github-app as a +// shallow-copied map when present and valid. +func extractRawOTLPGitHubAppMap(obs map[string]any) map[string]any { + if obs == nil { + return nil + } + otlpAny, ok := obs["otlp"] + if !ok { + return nil + } + otlpMap, ok := otlpAny.(map[string]any) + if !ok { + return nil + } + githubAppAny, ok := otlpMap["github-app"] + if !ok { + return nil + } + githubAppMap, ok := githubAppAny.(map[string]any) + if !ok { + return nil + } + copied := make(map[string]any, len(githubAppMap)) + maps.Copy(copied, githubAppMap) + return copied +} + // endpoint entry. Duplicate pairs are included as-is; the result is used only // for secret-masking and contains no sensitive data itself after runtime // expression substitution by GitHub Actions. diff --git a/pkg/workflow/observability_otlp_test.go b/pkg/workflow/observability_otlp_test.go index 9bf6c1c65eb..1fc6f45e243 100644 --- a/pkg/workflow/observability_otlp_test.go +++ b/pkg/workflow/observability_otlp_test.go @@ -239,8 +239,7 @@ func TestGetOTLPGitHubOIDCAudience(t *testing.T) { t.Run("returns empty when github-app is missing", func(t *testing.T) { got := getOTLPGitHubOIDCAudience(nil, map[string]any{ "observability": map[string]any{ - "otlp": map[string]any{ - }, + "otlp": map[string]any{}, }, }) assert.Empty(t, got) @@ -329,8 +328,7 @@ func TestHasOTLPGitHubOIDCAuth(t *testing.T) { assert.True(t, hasOTLPGitHubOIDCAuth(nil, map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ - "github-app": map[string]any{ - }, + "github-app": map[string]any{}, }, }, })) @@ -1805,6 +1803,36 @@ func TestExtractRawOTLPEndpointMaps(t *testing.T) { } } +func TestExtractRawOTLPGitHubAppMap(t *testing.T) { + t.Run("returns shallow copy when github-app exists", func(t *testing.T) { + obs := map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{ + "audience": "api://AzureADTokenExchange", + }, + }, + } + + got := extractRawOTLPGitHubAppMap(obs) + require.NotNil(t, got) + assert.Equal(t, "api://AzureADTokenExchange", got["audience"]) + + got["audience"] = "changed" + original := obs["otlp"].(map[string]any)["github-app"].(map[string]any)["audience"] + assert.Equal(t, "api://AzureADTokenExchange", original) + }) + + t.Run("returns nil for invalid values", func(t *testing.T) { + assert.Nil(t, extractRawOTLPGitHubAppMap(nil)) + assert.Nil(t, extractRawOTLPGitHubAppMap(map[string]any{})) + assert.Nil(t, extractRawOTLPGitHubAppMap(map[string]any{ + "otlp": map[string]any{ + "github-app": "invalid", + }, + })) + }) +} + // TestCollectOTLPCustomAttributes verifies that custom attributes are read from the // frontmatter and returned as a map[string]string. func TestCollectOTLPCustomAttributes(t *testing.T) { diff --git a/pkg/workflow/setup_step_version_test.go b/pkg/workflow/setup_step_version_test.go index 5bb1ee51855..74ed550bc9c 100644 --- a/pkg/workflow/setup_step_version_test.go +++ b/pkg/workflow/setup_step_version_test.go @@ -302,6 +302,32 @@ func TestGenerateSetupStepIncludesOTLPOIDCMintingBeforeSetup(t *testing.T) { } } +func TestGenerateSetupStepIncludesOTLPOIDCMintingFromParsedFrontmatter(t *testing.T) { + c := NewCompiler() + data := &WorkflowData{ + Name: "my-workflow", + ParsedFrontmatter: &FrontmatterConfig{ + Observability: &ObservabilityConfig{ + OTLP: &OTLPConfig{ + GitHubApp: &OTLPGitHubAppConfig{ + Audience: "https://example.com/collector", + }, + }, + }, + }, + } + + lines := c.generateSetupStep(data, "github/gh-aw/actions/setup@abc123", "${{ runner.temp }}/gh-aw", false, "", "") + combined := strings.Join(lines, "") + + if !strings.Contains(combined, "id: mint-otlp-oidc-token") { + t.Fatalf("expected setup step to include OTLP OIDC mint step from parsed frontmatter, got:\n%s", combined) + } + if !strings.Contains(combined, "GH_AW_OTLP_OIDC_AUDIENCE") { + t.Fatalf("expected mint step to include OTLP OIDC audience env from parsed frontmatter, got:\n%s", combined) + } +} + func TestGenerateSetupStepIncludesOTLPOIDCTokenInScriptMode(t *testing.T) { c := NewCompiler() c.SetActionMode(ActionModeScript) @@ -310,8 +336,7 @@ func TestGenerateSetupStepIncludesOTLPOIDCTokenInScriptMode(t *testing.T) { RawFrontmatter: map[string]any{ "observability": map[string]any{ "otlp": map[string]any{ - "github-app": map[string]any{ - }, + "github-app": map[string]any{}, }, }, }, From c5e523643beb27f39121e512254e67deb85b6670 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 10:47:15 +0000 Subject: [PATCH 11/26] Strengthen parsed-frontmatter OTLP mint step assertion Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/setup_step_version_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/workflow/setup_step_version_test.go b/pkg/workflow/setup_step_version_test.go index 74ed550bc9c..acc07004f68 100644 --- a/pkg/workflow/setup_step_version_test.go +++ b/pkg/workflow/setup_step_version_test.go @@ -326,6 +326,9 @@ func TestGenerateSetupStepIncludesOTLPOIDCMintingFromParsedFrontmatter(t *testin if !strings.Contains(combined, "GH_AW_OTLP_OIDC_AUDIENCE") { t.Fatalf("expected mint step to include OTLP OIDC audience env from parsed frontmatter, got:\n%s", combined) } + if !strings.Contains(combined, "https://example.com/collector") { + t.Fatalf("expected mint step to include parsed frontmatter OTLP OIDC audience value, got:\n%s", combined) + } } func TestGenerateSetupStepIncludesOTLPOIDCTokenInScriptMode(t *testing.T) { From 320a500be56ddaae04c036ccb444c0896c38d5e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 11:02:05 +0000 Subject: [PATCH 12/26] test: assert workflow fixtures emit create-github-app-token action Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/top_level_github_app_integration_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/workflow/top_level_github_app_integration_test.go b/pkg/workflow/top_level_github_app_integration_test.go index 187efcf9924..8656c94ce1d 100644 --- a/pkg/workflow/top_level_github_app_integration_test.go +++ b/pkg/workflow/top_level_github_app_integration_test.go @@ -288,6 +288,7 @@ func TestTopLevelGitHubAppWorkflowFiles(t *testing.T) { workflowFile: "../cli/workflows/test-top-level-github-app-safe-outputs.md", expectContains: []string{ "id: safe-outputs-app-token", + "uses: actions/create-github-app-token", "client-id: ${{ vars.APP_ID }}", "private-key: ${{ secrets.APP_PRIVATE_KEY }}", }, @@ -297,6 +298,7 @@ func TestTopLevelGitHubAppWorkflowFiles(t *testing.T) { workflowFile: "../cli/workflows/test-top-level-github-app-activation.md", expectContains: []string{ "id: activation-app-token", + "uses: actions/create-github-app-token", "client-id: ${{ vars.APP_ID }}", "github-token: ${{ steps.activation-app-token.outputs.token }}", }, @@ -306,6 +308,7 @@ func TestTopLevelGitHubAppWorkflowFiles(t *testing.T) { workflowFile: "../cli/workflows/test-top-level-github-app-checkout.md", expectContains: []string{ "id: checkout-app-token-0", + "uses: actions/create-github-app-token", "client-id: ${{ vars.APP_ID }}", }, }, @@ -313,6 +316,7 @@ func TestTopLevelGitHubAppWorkflowFiles(t *testing.T) { name: "section-specific override workflow file", workflowFile: "../cli/workflows/test-top-level-github-app-override.md", expectContains: []string{ + "uses: actions/create-github-app-token", "client-id: ${{ vars.SAFE_OUTPUTS_APP_ID }}", "client-id: ${{ vars.ACTIVATION_APP_ID }}", }, @@ -322,6 +326,7 @@ func TestTopLevelGitHubAppWorkflowFiles(t *testing.T) { workflowFile: "../cli/workflows/test-top-level-github-app-mcp.md", expectContains: []string{ "id: github-mcp-app-token", + "uses: actions/create-github-app-token", "client-id: ${{ vars.APP_ID }}", }, }, From 28c05432c0feb130a9309672a1b6ac00c192df21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 11:28:40 +0000 Subject: [PATCH 13/26] test: add OTLP github-app workflow fixture integration coverage Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../test-top-level-github-app-otlp.md | 32 +++++++++++++++++++ .../top_level_github_app_integration_test.go | 9 ++++++ 2 files changed, 41 insertions(+) create mode 100644 pkg/cli/workflows/test-top-level-github-app-otlp.md diff --git a/pkg/cli/workflows/test-top-level-github-app-otlp.md b/pkg/cli/workflows/test-top-level-github-app-otlp.md new file mode 100644 index 00000000000..aa6692d90c2 --- /dev/null +++ b/pkg/cli/workflows/test-top-level-github-app-otlp.md @@ -0,0 +1,32 @@ +--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read + pull-requests: read + id-token: write + +github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +observability: + otlp: + endpoint: ${{ secrets.GH_AW_OTEL_ENDPOINT }} + github-app: + type: github-oidc +tools: + github: + mode: remote + toolsets: [default] +safe-outputs: + create-issue: + title-prefix: "[automated] " +engine: copilot +--- + +# Top-Level GitHub App with OTLP OIDC + GitHub MCP + +This workflow exercises `observability.otlp.github-app` token minting together with +top-level `github-app` fallback used by `tools.github`. diff --git a/pkg/workflow/top_level_github_app_integration_test.go b/pkg/workflow/top_level_github_app_integration_test.go index 8656c94ce1d..77acfe3d925 100644 --- a/pkg/workflow/top_level_github_app_integration_test.go +++ b/pkg/workflow/top_level_github_app_integration_test.go @@ -330,6 +330,15 @@ func TestTopLevelGitHubAppWorkflowFiles(t *testing.T) { "client-id: ${{ vars.APP_ID }}", }, }, + { + name: "otlp github-app workflow file", + workflowFile: "../cli/workflows/test-top-level-github-app-otlp.md", + expectContains: []string{ + "id: mint-otlp-oidc-token", + "id: github-mcp-app-token", + "uses: actions/create-github-app-token", + }, + }, } for _, tt := range tests { From f529e28aa3e46043016e3e9548f09bcd5f326cd0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:02:25 +0000 Subject: [PATCH 14/26] Support OTLP github-app credential sample and token minting Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/syntax.md | 9 ++--- .../test-top-level-github-app-otlp.md | 12 +++---- pkg/parser/schemas/main_workflow_schema.json | 32 ++++++++++------- pkg/workflow/compiler_validators_test.go | 21 +++++++++++ pkg/workflow/compiler_yaml_step_generation.go | 11 ++++++ pkg/workflow/observability_otlp.go | 36 +++++++++++++++++++ pkg/workflow/observability_otlp_test.go | 11 ++++++ .../top_level_github_app_integration_test.go | 2 +- 8 files changed, 108 insertions(+), 26 deletions(-) diff --git a/.github/aw/syntax.md b/.github/aw/syntax.md index 3d661bb4a0d..84185420a17 100644 --- a/.github/aw/syntax.md +++ b/.github/aw/syntax.md @@ -291,9 +291,9 @@ The YAML frontmatter supports these fields: - **`otlp:`** - Export OpenTelemetry spans to any OTLP-compatible backend (Honeycomb, Grafana Tempo, Sentry, etc.) (object) - `endpoint:` - OTLP collector endpoint URL. When a static URL is provided, its hostname is added to the AWF firewall allowlist automatically. Supports GitHub Actions expressions. - `github-app:` - Optional runtime auth configuration. - - When configured, gh-aw mints a GitHub Actions OIDC credential before `actions/setup` and uses it for OTLP Authorization headers. - - `audience:` - Optional OIDC audience passed to `core.getIDToken(audience)`. - - Requires `permissions.id-token: write` on the workflow/job. + - Preferred: provide GitHub App credentials (`app-id`/`client-id` + `private-key`) to mint a token with `actions/create-github-app-token` before `actions/setup`. + - Legacy OIDC mode is also supported with `type: github-oidc` and optional `audience`. + - OIDC mode requires `permissions.id-token: write` on the workflow/job. - `headers:` - Comma-separated `key=value` HTTP headers included in every OTLP export request (e.g. `Authorization=Bearer `). Injected as `OTEL_EXPORTER_OTLP_HEADERS`. Supports GitHub Actions expressions. - Example: @@ -302,7 +302,8 @@ The YAML frontmatter supports these fields: otlp: endpoint: ${{ secrets.GH_AW_OTEL_ENDPOINT }} github-app: - audience: api://AzureADTokenExchange + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} headers: ${{ secrets.GH_AW_OTEL_HEADERS }} ``` diff --git a/pkg/cli/workflows/test-top-level-github-app-otlp.md b/pkg/cli/workflows/test-top-level-github-app-otlp.md index aa6692d90c2..60826f02ba5 100644 --- a/pkg/cli/workflows/test-top-level-github-app-otlp.md +++ b/pkg/cli/workflows/test-top-level-github-app-otlp.md @@ -6,16 +6,12 @@ permissions: contents: read issues: read pull-requests: read - id-token: write - -github-app: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} observability: otlp: endpoint: ${{ secrets.GH_AW_OTEL_ENDPOINT }} github-app: - type: github-oidc + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} tools: github: mode: remote @@ -26,7 +22,7 @@ safe-outputs: engine: copilot --- -# Top-Level GitHub App with OTLP OIDC + GitHub MCP +# OTLP GitHub App token minting with GitHub MCP This workflow exercises `observability.otlp.github-app` token minting together with -top-level `github-app` fallback used by `tools.github`. +`tools.github`. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 70c4962cddf..71bef147f98 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9670,21 +9670,27 @@ "description": "How to handle missing OTLP endpoint/header values at runtime (for example from unset secrets). 'error' fails workflow startup (default), 'warn' logs a warning and skips MCP gateway OTLP configuration, and 'ignore' skips MCP gateway OTLP configuration without warning. This affects MCP gateway setup only; workflow-level OTEL_* environment variables are still injected." }, "github-app": { - "type": "object", - "description": "Optional runtime authentication for OTLP export.", - "properties": { - "type": { - "type": "string", - "enum": ["github-oidc"], - "description": "Authentication type. 'github-oidc' mints a GitHub Actions OIDC credential before actions/setup and uses it in the OTLP Authorization header." + "description": "Optional runtime authentication for OTLP export. Supports either GitHub App credentials (client-id/app-id + private-key) or legacy GitHub OIDC settings.", + "oneOf": [ + { + "$ref": "#/$defs/github_app" }, - "audience": { - "type": "string", - "description": "Optional OIDC audience passed to core.getIDToken(audience) when github-app.type is github-oidc." + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["github-oidc"], + "description": "Authentication type. 'github-oidc' mints a GitHub Actions OIDC credential before actions/setup and uses it in the OTLP Authorization header." + }, + "audience": { + "type": "string", + "description": "Optional OIDC audience passed to core.getIDToken(audience)." + } + }, + "additionalProperties": false } - }, - "required": ["type"], - "additionalProperties": false + ] } }, "additionalProperties": false diff --git a/pkg/workflow/compiler_validators_test.go b/pkg/workflow/compiler_validators_test.go index 10c12e82066..4f01fd17489 100644 --- a/pkg/workflow/compiler_validators_test.go +++ b/pkg/workflow/compiler_validators_test.go @@ -251,6 +251,27 @@ func TestValidatePermissions(t *testing.T) { shouldError: false, wantPermissions: true, }, + { + name: "observability otlp github-app credentials do not require id-token write", + workflowData: &WorkflowData{ + Name: "Test", + MarkdownContent: "# Test", + AI: "copilot", + Permissions: "permissions:\n contents: read\n", + RawFrontmatter: map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + }, + }, + shouldError: false, + wantPermissions: true, + }, } for _, tt := range tests { diff --git a/pkg/workflow/compiler_yaml_step_generation.go b/pkg/workflow/compiler_yaml_step_generation.go index a7f6f726631..bb2a2a94a77 100644 --- a/pkg/workflow/compiler_yaml_step_generation.go +++ b/pkg/workflow/compiler_yaml_step_generation.go @@ -133,6 +133,17 @@ func (c *Compiler) generateOTLPOIDCMintStep(data *WorkflowData) []string { return nil } + if app := getOTLPGitHubAppTokenConfig(data.RawFrontmatter); app != nil { + compilerYamlStepGenerationLog.Print("Generating OTLP GitHub App token mint step before setup") + lines := c.buildGitHubAppTokenMintStep(app, nil, "") + for i, line := range lines { + line = strings.Replace(line, "Generate GitHub App token", "Mint OTLP GitHub App token", 1) + line = strings.Replace(line, "id: safe-outputs-app-token", "id: mint-otlp-oidc-token", 1) + lines[i] = line + } + return lines + } + githubApp := getOTLPGitHubApp(data.ParsedFrontmatter, data.RawFrontmatter) if githubApp == nil { return nil diff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go index 3e869dc1683..e0268a01007 100644 --- a/pkg/workflow/observability_otlp.go +++ b/pkg/workflow/observability_otlp.go @@ -200,6 +200,38 @@ func getOTLPGitHubApp(config *FrontmatterConfig, frontmatter map[string]any) *OT } } +func getOTLPGitHubAppTokenConfig(frontmatter map[string]any) *GitHubAppConfig { + if frontmatter == nil { + return nil + } + + obsAny, ok := frontmatter["observability"] + if !ok { + return nil + } + + obsMap, ok := obsAny.(map[string]any) + if !ok { + return nil + } + + githubAppMap := extractRawOTLPGitHubAppMap(obsMap) + if githubAppMap == nil { + return nil + } + + app := parseAppConfig(githubAppMap) + if app == nil { + return nil + } + + if strings.TrimSpace(app.AppID) == "" || strings.TrimSpace(app.PrivateKey) == "" { + return nil + } + + return app +} + // getOTLPGitHubOIDCAudience returns observability.otlp.github-app.audience. // Returns empty string when github-app is unset or invalid. func getOTLPGitHubOIDCAudience(config *FrontmatterConfig, frontmatter map[string]any) string { @@ -212,6 +244,10 @@ func getOTLPGitHubOIDCAudience(config *FrontmatterConfig, frontmatter map[string } func hasOTLPGitHubOIDCAuth(config *FrontmatterConfig, frontmatter map[string]any) bool { + if getOTLPGitHubAppTokenConfig(frontmatter) != nil { + return false + } + return getOTLPGitHubApp(config, frontmatter) != nil } diff --git a/pkg/workflow/observability_otlp_test.go b/pkg/workflow/observability_otlp_test.go index 1fc6f45e243..aac5bb59485 100644 --- a/pkg/workflow/observability_otlp_test.go +++ b/pkg/workflow/observability_otlp_test.go @@ -338,6 +338,17 @@ func TestHasOTLPGitHubOIDCAuth(t *testing.T) { "otlp": map[string]any{}, }, })) + + assert.False(t, hasOTLPGitHubOIDCAuth(nil, map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + })) } // TestInjectOTLPConfig verifies that injectOTLPConfig correctly modifies WorkflowData. diff --git a/pkg/workflow/top_level_github_app_integration_test.go b/pkg/workflow/top_level_github_app_integration_test.go index 77acfe3d925..bcab4733ba2 100644 --- a/pkg/workflow/top_level_github_app_integration_test.go +++ b/pkg/workflow/top_level_github_app_integration_test.go @@ -335,8 +335,8 @@ func TestTopLevelGitHubAppWorkflowFiles(t *testing.T) { workflowFile: "../cli/workflows/test-top-level-github-app-otlp.md", expectContains: []string{ "id: mint-otlp-oidc-token", - "id: github-mcp-app-token", "uses: actions/create-github-app-token", + "client-id: ${{ vars.APP_ID }}", }, }, } From 216e4759e1bbfd6c87b252f26225c010810dccf4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:05:28 +0000 Subject: [PATCH 15/26] Refine OTLP github-app mint-step generation and tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/compiler_yaml_step_generation.go | 8 +------- pkg/workflow/observability_otlp.go | 2 +- pkg/workflow/observability_otlp_test.go | 16 ++++++++++++++++ pkg/workflow/safe_outputs_app_config.go | 15 +++++++++++++-- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/pkg/workflow/compiler_yaml_step_generation.go b/pkg/workflow/compiler_yaml_step_generation.go index bb2a2a94a77..5b67c5d026f 100644 --- a/pkg/workflow/compiler_yaml_step_generation.go +++ b/pkg/workflow/compiler_yaml_step_generation.go @@ -135,13 +135,7 @@ func (c *Compiler) generateOTLPOIDCMintStep(data *WorkflowData) []string { if app := getOTLPGitHubAppTokenConfig(data.RawFrontmatter); app != nil { compilerYamlStepGenerationLog.Print("Generating OTLP GitHub App token mint step before setup") - lines := c.buildGitHubAppTokenMintStep(app, nil, "") - for i, line := range lines { - line = strings.Replace(line, "Generate GitHub App token", "Mint OTLP GitHub App token", 1) - line = strings.Replace(line, "id: safe-outputs-app-token", "id: mint-otlp-oidc-token", 1) - lines[i] = line - } - return lines + return c.buildGitHubAppTokenMintStepWithMeta(app, nil, "", "Mint OTLP GitHub App token", "mint-otlp-oidc-token") } githubApp := getOTLPGitHubApp(data.ParsedFrontmatter, data.RawFrontmatter) diff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go index e0268a01007..f62fe9d8e20 100644 --- a/pkg/workflow/observability_otlp.go +++ b/pkg/workflow/observability_otlp.go @@ -225,7 +225,7 @@ func getOTLPGitHubAppTokenConfig(frontmatter map[string]any) *GitHubAppConfig { return nil } - if strings.TrimSpace(app.AppID) == "" || strings.TrimSpace(app.PrivateKey) == "" { + if !app.hasRequiredCredentials() { return nil } diff --git a/pkg/workflow/observability_otlp_test.go b/pkg/workflow/observability_otlp_test.go index aac5bb59485..f8ce634142c 100644 --- a/pkg/workflow/observability_otlp_test.go +++ b/pkg/workflow/observability_otlp_test.go @@ -351,6 +351,22 @@ func TestHasOTLPGitHubOIDCAuth(t *testing.T) { })) } +func TestGetOTLPGitHubAppTokenConfig(t *testing.T) { + got := getOTLPGitHubAppTokenConfig(map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{ + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + }, + }) + require.NotNil(t, got) + assert.Equal(t, "${{ vars.APP_ID }}", got.AppID) + assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", got.PrivateKey) +} + // TestInjectOTLPConfig verifies that injectOTLPConfig correctly modifies WorkflowData. func TestInjectOTLPConfig(t *testing.T) { newCompiler := func() *Compiler { return &Compiler{} } diff --git a/pkg/workflow/safe_outputs_app_config.go b/pkg/workflow/safe_outputs_app_config.go index ff228c7e87b..76108849b8c 100644 --- a/pkg/workflow/safe_outputs_app_config.go +++ b/pkg/workflow/safe_outputs_app_config.go @@ -110,6 +110,13 @@ func (app *GitHubAppConfig) shouldIgnoreMissingKey() bool { return app.IgnoreIfMissing } +func (app *GitHubAppConfig) hasRequiredCredentials() bool { + if app == nil { + return false + } + return strings.TrimSpace(app.AppID) != "" && strings.TrimSpace(app.PrivateKey) != "" +} + // extractWrappedGitHubExpression returns the inner text for values wrapped as // `${{ ... }}` (for example, `${{ secrets.APP_ID }}` -> `secrets.APP_ID`). // It returns false for literals and malformed/empty wrappers. @@ -203,11 +210,15 @@ func (c *Compiler) mergeAppFromIncludedConfigs(topSafeOutputs *SafeOutputsConfig // workflow_call relay workflows so the token is scoped to the platform repo's NAME, not the full // owner/repo slug — actions/create-github-app-token expects repo names only when owner is also set). func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string) []string { + return c.buildGitHubAppTokenMintStepWithMeta(app, permissions, fallbackRepoExpr, "Generate GitHub App token", "safe-outputs-app-token") +} + +func (c *Compiler) buildGitHubAppTokenMintStepWithMeta(app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string, stepName string, stepID string) []string { safeOutputsAppLog.Printf("Building GitHub App token mint step: owner=%s, repos=%d", app.Owner, len(app.Repositories)) var steps []string - steps = append(steps, " - name: Generate GitHub App token\n") - steps = append(steps, " id: safe-outputs-app-token\n") + steps = append(steps, fmt.Sprintf(" - name: %s\n", stepName)) + steps = append(steps, fmt.Sprintf(" id: %s\n", stepID)) if app.shouldIgnoreMissingKey() { steps = append(steps, fmt.Sprintf(" if: %s\n", buildIgnoreIfMissingCondition(app))) } From bda3c0c303c54e77baf89443ce702153d4016f05 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:07:46 +0000 Subject: [PATCH 16/26] Tighten OTLP GitHub App integration assertions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/compiler_validators_test.go | 2 +- pkg/workflow/top_level_github_app_integration_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/workflow/compiler_validators_test.go b/pkg/workflow/compiler_validators_test.go index 4f01fd17489..7cad7d11962 100644 --- a/pkg/workflow/compiler_validators_test.go +++ b/pkg/workflow/compiler_validators_test.go @@ -252,7 +252,7 @@ func TestValidatePermissions(t *testing.T) { wantPermissions: true, }, { - name: "observability otlp github-app credentials do not require id-token write", + name: "observability otlp GitHub App credentials do not require id-token write", workflowData: &WorkflowData{ Name: "Test", MarkdownContent: "# Test", diff --git a/pkg/workflow/top_level_github_app_integration_test.go b/pkg/workflow/top_level_github_app_integration_test.go index bcab4733ba2..33bb1b4aa44 100644 --- a/pkg/workflow/top_level_github_app_integration_test.go +++ b/pkg/workflow/top_level_github_app_integration_test.go @@ -337,6 +337,7 @@ func TestTopLevelGitHubAppWorkflowFiles(t *testing.T) { "id: mint-otlp-oidc-token", "uses: actions/create-github-app-token", "client-id: ${{ vars.APP_ID }}", + "private-key: ${{ secrets.APP_PRIVATE_KEY }}", }, }, } From f2c63768b01785394c39d00dfdb724362ebf856a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:09:55 +0000 Subject: [PATCH 17/26] Clean up OTLP GitHub App helper nil handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/observability_otlp.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go index f62fe9d8e20..813a0b28c43 100644 --- a/pkg/workflow/observability_otlp.go +++ b/pkg/workflow/observability_otlp.go @@ -221,10 +221,6 @@ func getOTLPGitHubAppTokenConfig(frontmatter map[string]any) *GitHubAppConfig { } app := parseAppConfig(githubAppMap) - if app == nil { - return nil - } - if !app.hasRequiredCredentials() { return nil } From a3026294997ecd3ee270d613d4a1d51acf23f68d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:34:31 +0000 Subject: [PATCH 18/26] Remove legacy OTLP github-oidc type syntax Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/syntax.md | 2 +- pkg/parser/schema_test.go | 47 ++++++++++++++++++++ pkg/parser/schemas/main_workflow_schema.json | 7 +-- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/.github/aw/syntax.md b/.github/aw/syntax.md index 84185420a17..3b13031f214 100644 --- a/.github/aw/syntax.md +++ b/.github/aw/syntax.md @@ -292,7 +292,7 @@ The YAML frontmatter supports these fields: - `endpoint:` - OTLP collector endpoint URL. When a static URL is provided, its hostname is added to the AWF firewall allowlist automatically. Supports GitHub Actions expressions. - `github-app:` - Optional runtime auth configuration. - Preferred: provide GitHub App credentials (`app-id`/`client-id` + `private-key`) to mint a token with `actions/create-github-app-token` before `actions/setup`. - - Legacy OIDC mode is also supported with `type: github-oidc` and optional `audience`. + - OIDC mode is implicit when GitHub App credentials are not provided; optional `audience` is supported. - OIDC mode requires `permissions.id-token: write` on the workflow/job. - `headers:` - Comma-separated `key=value` HTTP headers included in every OTLP export request (e.g. `Authorization=Bearer `). Injected as `OTEL_EXPORTER_OTLP_HEADERS`. Supports GitHub Actions expressions. - Example: diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index acf2760daea..fe30e981836 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -829,6 +829,53 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_GitHubAppClientID( } } +func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppAudienceOnly(t *testing.T) { + frontmatter := map[string]any{ + "name": "OTLP audience-only github-app config", + "on": map[string]any{ + "issues": map[string]any{ + "types": []any{"opened"}, + }, + }, + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{ + "audience": "https://collector.example.com", + }, + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/otlp-github-app-audience-only-schema-test.md") + if err != nil { + t.Fatalf("expected audience-only observability.otlp.github-app to pass schema validation, got: %v", err) + } +} + +func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppLegacyTypeRejected(t *testing.T) { + frontmatter := map[string]any{ + "name": "OTLP legacy github-oidc type rejection", + "on": map[string]any{ + "issues": map[string]any{ + "types": []any{"opened"}, + }, + }, + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{ + "type": "github-oidc", + "audience": "https://collector.example.com", + }, + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/otlp-github-app-legacy-type-schema-test.md") + if err == nil { + t.Fatal("expected legacy observability.otlp.github-app.type: github-oidc to fail schema validation") + } +} + // TestNormalizeForJSONSchema_NestedMap verifies recursive normalization of maps. func TestNormalizeForJSONSchema_NestedMap(t *testing.T) { input := map[string]any{ diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 71bef147f98..dffa258937e 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9670,7 +9670,7 @@ "description": "How to handle missing OTLP endpoint/header values at runtime (for example from unset secrets). 'error' fails workflow startup (default), 'warn' logs a warning and skips MCP gateway OTLP configuration, and 'ignore' skips MCP gateway OTLP configuration without warning. This affects MCP gateway setup only; workflow-level OTEL_* environment variables are still injected." }, "github-app": { - "description": "Optional runtime authentication for OTLP export. Supports either GitHub App credentials (client-id/app-id + private-key) or legacy GitHub OIDC settings.", + "description": "Optional runtime authentication for OTLP export. Supports GitHub App credentials (client-id/app-id + private-key) or implicit GitHub OIDC settings.", "oneOf": [ { "$ref": "#/$defs/github_app" @@ -9678,11 +9678,6 @@ { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["github-oidc"], - "description": "Authentication type. 'github-oidc' mints a GitHub Actions OIDC credential before actions/setup and uses it in the OTLP Authorization header." - }, "audience": { "type": "string", "description": "Optional OIDC audience passed to core.getIDToken(audience)." From 1b53d4da41be6a2e6a6e0e770c168152d638aa90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:35:40 +0000 Subject: [PATCH 19/26] Clarify implicit OTLP OIDC wording Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/syntax.md | 2 +- pkg/parser/schemas/main_workflow_schema.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/aw/syntax.md b/.github/aw/syntax.md index 3b13031f214..b9e682e7490 100644 --- a/.github/aw/syntax.md +++ b/.github/aw/syntax.md @@ -292,7 +292,7 @@ The YAML frontmatter supports these fields: - `endpoint:` - OTLP collector endpoint URL. When a static URL is provided, its hostname is added to the AWF firewall allowlist automatically. Supports GitHub Actions expressions. - `github-app:` - Optional runtime auth configuration. - Preferred: provide GitHub App credentials (`app-id`/`client-id` + `private-key`) to mint a token with `actions/create-github-app-token` before `actions/setup`. - - OIDC mode is implicit when GitHub App credentials are not provided; optional `audience` is supported. + - OIDC mode is used when `github-app` is configured without credentials (`app-id`/`client-id` + `private-key`); optional `audience` is supported. - OIDC mode requires `permissions.id-token: write` on the workflow/job. - `headers:` - Comma-separated `key=value` HTTP headers included in every OTLP export request (e.g. `Authorization=Bearer `). Injected as `OTEL_EXPORTER_OTLP_HEADERS`. Supports GitHub Actions expressions. - Example: diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index dffa258937e..3e745b4f85a 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9670,7 +9670,7 @@ "description": "How to handle missing OTLP endpoint/header values at runtime (for example from unset secrets). 'error' fails workflow startup (default), 'warn' logs a warning and skips MCP gateway OTLP configuration, and 'ignore' skips MCP gateway OTLP configuration without warning. This affects MCP gateway setup only; workflow-level OTEL_* environment variables are still injected." }, "github-app": { - "description": "Optional runtime authentication for OTLP export. Supports GitHub App credentials (client-id/app-id + private-key) or implicit GitHub OIDC settings.", + "description": "Optional runtime authentication for OTLP export. Supports GitHub App credentials (client-id/app-id + private-key) for token minting, or GitHub OIDC mode when the github-app object is present without credentials (optionally with audience).", "oneOf": [ { "$ref": "#/$defs/github_app" From a450fe4aa44f278923baafc0705c26cfcbc54dea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:36:29 +0000 Subject: [PATCH 20/26] Tighten legacy OTLP type rejection test Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schema_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index fe30e981836..f2ffe0af27c 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -874,6 +874,9 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppLegac if err == nil { t.Fatal("expected legacy observability.otlp.github-app.type: github-oidc to fail schema validation") } + if !strings.Contains(err.Error(), "github-app") || !strings.Contains(err.Error(), "type") { + t.Fatalf("expected schema validation error to reference removed github-app.type field, got: %v", err) + } } // TestNormalizeForJSONSchema_NestedMap verifies recursive normalization of maps. From 8ec1253c625e34cca373328de80c27ac97d1f389 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:37:43 +0000 Subject: [PATCH 21/26] Harden OTLP legacy schema error assertion Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schema_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index f2ffe0af27c..6a6b64f4199 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -874,8 +874,12 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppLegac if err == nil { t.Fatal("expected legacy observability.otlp.github-app.type: github-oidc to fail schema validation") } - if !strings.Contains(err.Error(), "github-app") || !strings.Contains(err.Error(), "type") { - t.Fatalf("expected schema validation error to reference removed github-app.type field, got: %v", err) + errText := err.Error() + if !strings.Contains(errText, "Unknown properties") || + !strings.Contains(errText, "type") || + !strings.Contains(errText, "audience") || + !strings.Contains(errText, "observability/otlp/github-app") { + t.Fatalf("expected schema validation error to reference unsupported legacy github-app.type syntax, got: %v", err) } } From d6b640bcd170d8cda63cecd60ccd26bf49ca0408 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 12:38:28 +0000 Subject: [PATCH 22/26] Simplify legacy OTLP type error assertion Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schema_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 6a6b64f4199..00775d165ad 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -877,7 +877,6 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppLegac errText := err.Error() if !strings.Contains(errText, "Unknown properties") || !strings.Contains(errText, "type") || - !strings.Contains(errText, "audience") || !strings.Contains(errText, "observability/otlp/github-app") { t.Fatalf("expected schema validation error to reference unsupported legacy github-app.type syntax, got: %v", err) } From 1208417f09aad74f2f25922cb80c6c4861080608 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 13:07:51 +0000 Subject: [PATCH 23/26] Simplify OTLP github-app schema shape Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/syntax.md | 2 +- pkg/parser/schema_test.go | 41 +++++++++++++---- pkg/parser/schemas/main_workflow_schema.json | 48 ++++++++++++++------ 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/.github/aw/syntax.md b/.github/aw/syntax.md index b9e682e7490..f5d99a183f6 100644 --- a/.github/aw/syntax.md +++ b/.github/aw/syntax.md @@ -292,7 +292,7 @@ The YAML frontmatter supports these fields: - `endpoint:` - OTLP collector endpoint URL. When a static URL is provided, its hostname is added to the AWF firewall allowlist automatically. Supports GitHub Actions expressions. - `github-app:` - Optional runtime auth configuration. - Preferred: provide GitHub App credentials (`app-id`/`client-id` + `private-key`) to mint a token with `actions/create-github-app-token` before `actions/setup`. - - OIDC mode is used when `github-app` is configured without credentials (`app-id`/`client-id` + `private-key`); optional `audience` is supported. + - OIDC mode is used when `github-app` is configured without credentials (`app-id`/`client-id` + `private-key`). - OIDC mode requires `permissions.id-token: write` on the workflow/job. - `headers:` - Comma-separated `key=value` HTTP headers included in every OTLP export request (e.g. `Authorization=Bearer `). Injected as `OTEL_EXPORTER_OTLP_HEADERS`. Supports GitHub Actions expressions. - Example: diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 00775d165ad..c351869b804 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -829,9 +829,30 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_GitHubAppClientID( } } -func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppAudienceOnly(t *testing.T) { +func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppImplicitOIDC(t *testing.T) { frontmatter := map[string]any{ - "name": "OTLP audience-only github-app config", + "name": "OTLP implicit OIDC github-app config", + "on": map[string]any{ + "issues": map[string]any{ + "types": []any{"opened"}, + }, + }, + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{}, + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/otlp-github-app-implicit-oidc-schema-test.md") + if err != nil { + t.Fatalf("expected empty observability.otlp.github-app to pass schema validation for implicit OIDC, got: %v", err) + } +} + +func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppAudienceRejected(t *testing.T) { + frontmatter := map[string]any{ + "name": "OTLP github-app audience rejection", "on": map[string]any{ "issues": map[string]any{ "types": []any{"opened"}, @@ -846,9 +867,14 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppAudie }, } - err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/otlp-github-app-audience-only-schema-test.md") - if err != nil { - t.Fatalf("expected audience-only observability.otlp.github-app to pass schema validation, got: %v", err) + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/otlp-github-app-audience-reject-schema-test.md") + if err == nil { + t.Fatal("expected observability.otlp.github-app.audience to fail schema validation") + } + errText := err.Error() + if !strings.Contains(errText, "audience") || + !strings.Contains(errText, "github-app") { + t.Fatalf("expected schema validation error to reference unsupported github-app.audience syntax, got: %v", err) } } @@ -875,9 +901,8 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppLegac t.Fatal("expected legacy observability.otlp.github-app.type: github-oidc to fail schema validation") } errText := err.Error() - if !strings.Contains(errText, "Unknown properties") || - !strings.Contains(errText, "type") || - !strings.Contains(errText, "observability/otlp/github-app") { + if !strings.Contains(errText, "type") || + !strings.Contains(errText, "github-app") { t.Fatalf("expected schema validation error to reference unsupported legacy github-app.type syntax, got: %v", err) } } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 3e745b4f85a..2dbcb30622f 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9670,22 +9670,42 @@ "description": "How to handle missing OTLP endpoint/header values at runtime (for example from unset secrets). 'error' fails workflow startup (default), 'warn' logs a warning and skips MCP gateway OTLP configuration, and 'ignore' skips MCP gateway OTLP configuration without warning. This affects MCP gateway setup only; workflow-level OTEL_* environment variables are still injected." }, "github-app": { - "description": "Optional runtime authentication for OTLP export. Supports GitHub App credentials (client-id/app-id + private-key) for token minting, or GitHub OIDC mode when the github-app object is present without credentials (optionally with audience).", - "oneOf": [ - { - "$ref": "#/$defs/github_app" + "description": "Optional runtime authentication for OTLP export. Supports GitHub App credentials (client-id/app-id + private-key) for token minting, or implicit GitHub OIDC mode when the github-app object is present without credentials.", + "type": "object", + "properties": { + "app-id": { + "type": "string", + "description": "Deprecated alias for client-id. GitHub App ID/client ID (e.g., '${{ vars.APP_ID }}')." }, - { - "type": "object", - "properties": { - "audience": { - "type": "string", - "description": "Optional OIDC audience passed to core.getIDToken(audience)." - } - }, - "additionalProperties": false + "client-id": { + "type": "string", + "description": "GitHub App client ID (e.g., '${{ vars.APP_ID }}')." + }, + "private-key": { + "type": "string", + "description": "GitHub App private key (e.g., '${{ secrets.APP_PRIVATE_KEY }}')." + }, + "ignore-if-missing": { + "type": "boolean", + "description": "If true, skip token minting when client-id/private-key resolve to empty strings at runtime. Defaults to false." + }, + "owner": { + "type": "string", + "description": "Optional owner of the GitHub App installation (defaults to current repository owner if not specified)" + }, + "repositories": { + "type": "array", + "description": "Optional list of repositories to grant access to (defaults to current repository if not specified)", + "items": { + "type": "string" + } + }, + "permissions": { + "$ref": "#/$defs/github_app_permissions", + "description": "Optional extra GitHub App-only permissions to merge into the minted token. Takes effect for tools.github.github-app and safe-outputs.github-app; ignored in on.github-app and the top-level github-app fallback. Use to add GitHub App-only scopes (e.g. members, organization-administration) not expressible via standard handler declarations." } - ] + }, + "additionalProperties": false } }, "additionalProperties": false From 81dd66741d701eb44bed6cdedd33adeb3ab44f51 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 13:11:06 +0000 Subject: [PATCH 24/26] Refine OTLP github-app schema validation tests Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schema_test.go | 4 ++-- pkg/parser/schemas/main_workflow_schema.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index c351869b804..d61cd6aa64c 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -873,7 +873,7 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppAudie } errText := err.Error() if !strings.Contains(errText, "audience") || - !strings.Contains(errText, "github-app") { + (!strings.Contains(errText, "github-app") && !strings.Contains(errText, "Unknown property")) { t.Fatalf("expected schema validation error to reference unsupported github-app.audience syntax, got: %v", err) } } @@ -902,7 +902,7 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppLegac } errText := err.Error() if !strings.Contains(errText, "type") || - !strings.Contains(errText, "github-app") { + (!strings.Contains(errText, "github-app") && !strings.Contains(errText, "Unknown properties")) { t.Fatalf("expected schema validation error to reference unsupported legacy github-app.type syntax, got: %v", err) } } diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 2dbcb30622f..7a604fb9738 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9691,11 +9691,11 @@ }, "owner": { "type": "string", - "description": "Optional owner of the GitHub App installation (defaults to current repository owner if not specified)" + "description": "Optional owner of the GitHub App installation (defaults to current repository owner if not specified)." }, "repositories": { "type": "array", - "description": "Optional list of repositories to grant access to (defaults to current repository if not specified)", + "description": "Optional list of repositories to grant access to (defaults to current repository if not specified).", "items": { "type": "string" } From 7fca136491c269f74dd7b865a5aeb987413d83e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 15:17:24 +0000 Subject: [PATCH 25/26] Simplify OTLP github-app schema fields Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/parser/schema_test.go | 30 ++++++++++++++++++++ pkg/parser/schemas/main_workflow_schema.json | 15 ---------- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index d61cd6aa64c..f711680b550 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -878,6 +878,36 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppAudie } } +func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppPermissionsRejected(t *testing.T) { + frontmatter := map[string]any{ + "name": "OTLP github-app permissions rejection", + "on": map[string]any{ + "issues": map[string]any{ + "types": []any{"opened"}, + }, + }, + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{ + "permissions": map[string]any{ + "contents": "read", + }, + }, + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/otlp-github-app-permissions-reject-schema-test.md") + if err == nil { + t.Fatal("expected observability.otlp.github-app.permissions to fail schema validation") + } + errText := err.Error() + if !strings.Contains(errText, "permissions") || + (!strings.Contains(errText, "github-app") && !strings.Contains(errText, "Unknown property")) { + t.Fatalf("expected schema validation error to reference unsupported github-app.permissions syntax, got: %v", err) + } +} + func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppLegacyTypeRejected(t *testing.T) { frontmatter := map[string]any{ "name": "OTLP legacy github-oidc type rejection", diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 7a604fb9738..6924a753cdd 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9688,21 +9688,6 @@ "ignore-if-missing": { "type": "boolean", "description": "If true, skip token minting when client-id/private-key resolve to empty strings at runtime. Defaults to false." - }, - "owner": { - "type": "string", - "description": "Optional owner of the GitHub App installation (defaults to current repository owner if not specified)." - }, - "repositories": { - "type": "array", - "description": "Optional list of repositories to grant access to (defaults to current repository if not specified).", - "items": { - "type": "string" - } - }, - "permissions": { - "$ref": "#/$defs/github_app_permissions", - "description": "Optional extra GitHub App-only permissions to merge into the minted token. Takes effect for tools.github.github-app and safe-outputs.github-app; ignored in on.github-app and the top-level github-app fallback. Use to add GitHub App-only scopes (e.g. members, organization-administration) not expressible via standard handler declarations." } }, "additionalProperties": false From 65680422126a0baab0cf31c03f49e8cd54c87131 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 17:51:52 +0000 Subject: [PATCH 26/26] Support OTLP github-app from imported workflows Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../shared/otlp-github-app-import.md | 10 ++++++ .../test-top-level-github-app-otlp-import.md | 23 ++++++++++++ pkg/parser/import_field_extractor.go | 36 ++++++++++++++++++- pkg/parser/import_field_extractor_test.go | 9 +++++ .../compiler_orchestrator_workflow.go | 17 ++++----- .../top_level_github_app_integration_test.go | 32 +++++++++++++++++ 6 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 pkg/cli/workflows/shared/otlp-github-app-import.md create mode 100644 pkg/cli/workflows/test-top-level-github-app-otlp-import.md diff --git a/pkg/cli/workflows/shared/otlp-github-app-import.md b/pkg/cli/workflows/shared/otlp-github-app-import.md new file mode 100644 index 00000000000..9843044f3c7 --- /dev/null +++ b/pkg/cli/workflows/shared/otlp-github-app-import.md @@ -0,0 +1,10 @@ +--- +observability: + otlp: + endpoint: ${{ secrets.GH_AW_OTEL_ENDPOINT }} + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + +Shared import that defines OTLP GitHub App auth. diff --git a/pkg/cli/workflows/test-top-level-github-app-otlp-import.md b/pkg/cli/workflows/test-top-level-github-app-otlp-import.md new file mode 100644 index 00000000000..cd90ae81d98 --- /dev/null +++ b/pkg/cli/workflows/test-top-level-github-app-otlp-import.md @@ -0,0 +1,23 @@ +--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read + pull-requests: read +imports: + - ./shared/otlp-github-app-import.md +tools: + github: + mode: remote + toolsets: [default] +safe-outputs: + create-issue: + title-prefix: "[automated] " +engine: copilot +--- + +# OTLP GitHub App token minting from import + +This workflow verifies `observability.otlp.github-app` is honored when configured in an imported workflow. diff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go index 73248671ad9..5831b456244 100644 --- a/pkg/parser/import_field_extractor.go +++ b/pkg/parser/import_field_extractor.go @@ -821,6 +821,7 @@ func mergeObservabilityConfigs(configs []string) string { seen := make(map[string]bool) var allEndpoints []observabilityImportEndpoint mergedAttrs := make(map[string]string) + var mergedGitHubApp map[string]any for i, cfgJSON := range configs { if cfgJSON == "" { @@ -842,9 +843,12 @@ func mergeObservabilityConfigs(configs []string) string { mergedAttrs[k] = v } } + if mergedGitHubApp == nil { + mergedGitHubApp = extractOTLPGitHubAppFromObsMap(obs) + } } - if len(allEndpoints) == 0 && len(mergedAttrs) == 0 { + if len(allEndpoints) == 0 && len(mergedAttrs) == 0 && mergedGitHubApp == nil { return "" } @@ -858,6 +862,9 @@ func mergeObservabilityConfigs(configs []string) string { if len(mergedAttrs) > 0 { otlpMap["attributes"] = mergedAttrs } + if mergedGitHubApp != nil { + otlpMap["github-app"] = mergedGitHubApp + } merged := map[string]any{"otlp": otlpMap} b, err := json.Marshal(merged) if err != nil { @@ -867,6 +874,33 @@ func mergeObservabilityConfigs(configs []string) string { return string(b) } +func extractOTLPGitHubAppFromObsMap(obs map[string]any) map[string]any { + if obs == nil { + return nil + } + otlpAny, ok := obs["otlp"] + if !ok { + return nil + } + otlpMap, ok := otlpAny.(map[string]any) + if !ok { + return nil + } + githubAppAny, ok := otlpMap["github-app"] + if !ok { + return nil + } + githubAppMap, ok := githubAppAny.(map[string]any) + if !ok { + return nil + } + copyMap := make(map[string]any, len(githubAppMap)) + for k, v := range githubAppMap { + copyMap[k] = v + } + return copyMap +} + // extractOTLPAttributesFromObsMap reads the custom OTLP attributes map from a // raw observability section (as parsed from an import's frontmatter). Only // string values are accepted; non-string values are silently ignored. diff --git a/pkg/parser/import_field_extractor_test.go b/pkg/parser/import_field_extractor_test.go index de8d7fd6305..f707c1d0f03 100644 --- a/pkg/parser/import_field_extractor_test.go +++ b/pkg/parser/import_field_extractor_test.go @@ -631,4 +631,13 @@ func TestMergeObservabilityConfigs(t *testing.T) { got := mergeObservabilityConfigs(configs) assert.Empty(t, got, "config without endpoints should return empty string") }) + + t.Run("github-app config is preserved even when no endpoints are set", func(t *testing.T) { + configs := []string{`{"otlp":{"github-app":{"app-id":"${{ vars.APP_ID }}","private-key":"${{ secrets.APP_PRIVATE_KEY }}"}}}`} + got := mergeObservabilityConfigs(configs) + require.NotEmpty(t, got, "github-app-only config should still produce merged observability") + assert.Contains(t, got, `"github-app"`, "should include github-app block") + assert.Contains(t, got, `"app-id":"${{ vars.APP_ID }}"`, "should preserve app-id") + assert.Contains(t, got, `"private-key":"${{ secrets.APP_PRIVATE_KEY }}"`, "should preserve private-key") + }) } diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index b10ce10778d..e328c869d42 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -220,7 +220,15 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) // mergeOTLPCustomAttributes(base, override) — base wins, so pass main as base. mergedAttrs := mergeOTLPCustomAttributes(mainAttrs, importAttrs) - if len(mergedEndpoints) > 0 || len(mergedAttrs) > 0 { + // Preserve OTLP github-app auth config so the compiler can emit the + // pre-setup OIDC mint step and validate id-token permissions. + // Main workflow takes precedence over imported defaults. + githubApp := extractRawOTLPGitHubAppMap(mainObs) + if githubApp == nil { + githubApp = extractRawOTLPGitHubAppMap(importedObs) + } + + if len(mergedEndpoints) > 0 || len(mergedAttrs) > 0 || githubApp != nil { mainCount := len(mergedEndpoints) - importAdded newOTLP := map[string]any{} if len(mergedEndpoints) > 0 { @@ -229,13 +237,6 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) if len(mergedAttrs) > 0 { newOTLP["attributes"] = mergedAttrs } - // Preserve OTLP github-app auth config so the compiler can emit the - // pre-setup OIDC mint step and validate id-token permissions. - // Main workflow takes precedence over imported defaults. - githubApp := extractRawOTLPGitHubAppMap(mainObs) - if githubApp == nil { - githubApp = extractRawOTLPGitHubAppMap(importedObs) - } if githubApp != nil { newOTLP["github-app"] = githubApp } diff --git a/pkg/workflow/top_level_github_app_integration_test.go b/pkg/workflow/top_level_github_app_integration_test.go index 33bb1b4aa44..61294d0bc85 100644 --- a/pkg/workflow/top_level_github_app_integration_test.go +++ b/pkg/workflow/top_level_github_app_integration_test.go @@ -368,3 +368,35 @@ func TestTopLevelGitHubAppWorkflowFiles(t *testing.T) { }) } } + +func TestTopLevelGitHubAppOTLPImportWorkflowFile(t *testing.T) { + tmpDir := testutil.TempDir(t, "top-level-github-app-otlp-import-workflow-file-test") + require.NoError(t, os.MkdirAll(filepath.Join(tmpDir, "shared"), 0700)) + + mainSrc := "../cli/workflows/test-top-level-github-app-otlp-import.md" + sharedSrc := "../cli/workflows/shared/otlp-github-app-import.md" + + mainContent, err := os.ReadFile(mainSrc) + require.NoError(t, err) + sharedContent, err := os.ReadFile(sharedSrc) + require.NoError(t, err) + + mainDst := filepath.Join(tmpDir, "test-top-level-github-app-otlp-import.md") + sharedDst := filepath.Join(tmpDir, "shared", "otlp-github-app-import.md") + require.NoError(t, os.WriteFile(mainDst, mainContent, 0600)) + require.NoError(t, os.WriteFile(sharedDst, sharedContent, 0600)) + + compiler := NewCompiler() + err = compiler.CompileWorkflow(mainDst) + require.NoError(t, err, "Workflow file with imported OTLP github-app should compile successfully") + + lockPath := filepath.Join(tmpDir, "test-top-level-github-app-otlp-import.lock.yml") + compiledBytes, err := os.ReadFile(lockPath) + require.NoError(t, err) + compiled := string(compiledBytes) + + assert.Contains(t, compiled, "id: mint-otlp-oidc-token") + assert.Contains(t, compiled, "uses: actions/create-github-app-token") + assert.Contains(t, compiled, "client-id: ${{ vars.APP_ID }}") + assert.Contains(t, compiled, "private-key: ${{ secrets.APP_PRIVATE_KEY }}") +}