diff --git a/.github/aw/syntax.md b/.github/aw/syntax.md index 8876f2fa72f..f5d99a183f6 100644 --- a/.github/aw/syntax.md +++ b/.github/aw/syntax.md @@ -290,6 +290,10 @@ 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. + - `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`). + - 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: @@ -297,6 +301,9 @@ The YAML frontmatter supports these fields: observability: otlp: endpoint: ${{ secrets.GH_AW_OTEL_ENDPOINT }} + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} 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..04c80d3bf5d 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 { ...entry, headers: mergedHeaders }; + }); + 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. @@ -86,6 +127,22 @@ 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 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) { 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..aaaa50a4ffd 100644 --- a/actions/setup/js/action_setup_otlp.test.cjs +++ b/actions/setup/js/action_setup_otlp.test.cjs @@ -63,6 +63,9 @@ 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, + GH_AW_OTLP_ENDPOINTS: process.env.GH_AW_OTLP_ENDPOINTS, + OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS, }; delete process.env.GH_AW_OTLP_ENDPOINTS; @@ -73,6 +76,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 +153,47 @@ 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=******"); + }); + + 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", () => { it("should pass startMs=0 when SETUP_START_MS is not set", async () => { await run(); 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/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..60826f02ba5 --- /dev/null +++ b/pkg/cli/workflows/test-top-level-github-app-otlp.md @@ -0,0 +1,28 @@ +--- +on: + issues: + types: [opened] +permissions: + contents: read + issues: read + pull-requests: read +observability: + otlp: + endpoint: ${{ secrets.GH_AW_OTEL_ENDPOINT }} + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +tools: + github: + mode: remote + toolsets: [default] +safe-outputs: + create-issue: + title-prefix: "[automated] " +engine: copilot +--- + +# OTLP GitHub App token minting with GitHub MCP + +This workflow exercises `observability.otlp.github-app` token minting together with +`tools.github`. 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/parser/schema_test.go b/pkg/parser/schema_test.go index acf2760daea..f711680b550 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -829,6 +829,114 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_GitHubAppClientID( } } +func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppImplicitOIDC(t *testing.T) { + frontmatter := map[string]any{ + "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"}, + }, + }, + "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-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") && !strings.Contains(errText, "Unknown property")) { + t.Fatalf("expected schema validation error to reference unsupported github-app.audience syntax, got: %v", err) + } +} + +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", + "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") + } + errText := err.Error() + if !strings.Contains(errText, "type") || + (!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) + } +} + // 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 7e4a8b7a56b..6924a753cdd 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9668,6 +9668,29 @@ "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." + }, + "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 }}')." + }, + "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." + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index a7e9d01625c..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,6 +237,9 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error) if len(mergedAttrs) > 0 { newOTLP["attributes"] = mergedAttrs } + if githubApp != nil { + newOTLP["github-app"] = githubApp + } workflowData.RawFrontmatter["observability"] = map[string]any{ "otlp": newOTLP, } diff --git a/pkg/workflow/compiler_validators_test.go b/pkg/workflow/compiler_validators_test.go index a537c8096a2..7cad7d11962 100644 --- a/pkg/workflow/compiler_validators_test.go +++ b/pkg/workflow/compiler_validators_test.go @@ -214,6 +214,64 @@ func TestValidatePermissions(t *testing.T) { shouldError: false, wantPermissions: true, }, + { + name: "observability otlp github-app 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{}, + }, + }, + }, + }, + shouldError: true, + errorContains: "observability.otlp.github-app requires permissions.id-token: write", + wantPermissions: false, + }, + { + name: "observability otlp github-app 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{}, + }, + }, + }, + }, + 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 ad797c704fb..5b67c5d026f 100644 --- a/pkg/workflow/compiler_yaml_step_generation.go +++ b/pkg/workflow/compiler_yaml_step_generation.go @@ -128,7 +128,46 @@ 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 { + return nil + } + + if app := getOTLPGitHubAppTokenConfig(data.RawFrontmatter); app != nil { + compilerYamlStepGenerationLog.Print("Generating OTLP GitHub App token mint step before setup") + return c.buildGitHubAppTokenMintStepWithMeta(app, nil, "", "Mint OTLP GitHub App token", "mint-otlp-oidc-token") + } + + githubApp := getOTLPGitHubApp(data.ParsedFrontmatter, data.RawFrontmatter) + if githubApp == nil { + 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 := strings.TrimSpace(githubApp.Audience); 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 +179,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 +189,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 +233,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..92fe12a603f 100644 --- a/pkg/workflow/frontmatter_types.go +++ b/pkg/workflow/frontmatter_types.go @@ -205,6 +205,13 @@ type OTLPEndpointConfig struct { Headers any `json:"headers,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"` +} + // OTLPConfig holds configuration for OTLP (OpenTelemetry Protocol) trace export. type OTLPConfig struct { // Endpoint accepts one of three forms: @@ -249,6 +256,15 @@ type OTLPConfig struct { // langfuse.user.id: "{{ github.actor }}" // user.id: "{{ github.actor }}" Attributes map[string]string `json:"attributes,omitempty"` + + // GitHubApp configures runtime OTLP authentication via the `github-app` key. + // Supported values: + // github-app: + // 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 *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 7779c886b6b..813a0b28c43 100644 --- a/pkg/workflow/observability_otlp.go +++ b/pkg/workflow/observability_otlp.go @@ -163,6 +163,90 @@ func getOTLPEndpointEnvValue(config *FrontmatterConfig) string { return "" } +func getOTLPGitHubApp(config *FrontmatterConfig, frontmatter map[string]any) *OTLPGitHubAppConfig { + if config != nil && config.Observability != nil && config.Observability.OTLP != nil && config.Observability.OTLP.GitHubApp != nil { + return config.Observability.OTLP.GitHubApp + } + if frontmatter == nil { + return nil + } + obsAny, ok := frontmatter["observability"] + if !ok { + return nil + } + obsMap, ok := obsAny.(map[string]any) + if !ok { + return nil + } + otlpAny, ok := obsMap["otlp"] + if !ok { + return nil + } + otlpMap, ok := otlpAny.(map[string]any) + if !ok { + return nil + } + authAny, ok := otlpMap["github-app"] + if !ok { + return nil + } + authMap, ok := authAny.(map[string]any) + if !ok { + return nil + } + audience, _ := authMap["audience"].(string) + return &OTLPGitHubAppConfig{ + Audience: audience, + } +} + +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.hasRequiredCredentials() { + 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 { + githubApp := getOTLPGitHubApp(config, frontmatter) + if githubApp == nil { + return "" + } + + return strings.TrimSpace(githubApp.Audience) +} + +func hasOTLPGitHubOIDCAuth(config *FrontmatterConfig, frontmatter map[string]any) bool { + if getOTLPGitHubAppTokenConfig(frontmatter) != nil { + return false + } + + return getOTLPGitHubApp(config, frontmatter) != nil +} + // normalizeOTLPIfMissingMode returns a validated if-missing mode. // Empty string means "unset/default (error)". func normalizeOTLPIfMissingMode(mode string) string { @@ -509,6 +593,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 361fdf88289..f8ce634142c 100644 --- a/pkg/workflow/observability_otlp_test.go +++ b/pkg/workflow/observability_otlp_test.go @@ -222,6 +222,151 @@ func TestGetOTLPIfMissingMode(t *testing.T) { }) } +func TestGetOTLPGitHubOIDCAudience(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: &OTLPGitHubAppConfig{ + Audience: "https://collector.example.com", + }, + }, + }, + }, nil) + assert.Equal(t, "https://collector.example.com", got) + }) + + 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{}, + }, + }) + assert.Empty(t, got) + }) + + 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{ + "audience": "api://AzureADTokenExchange", + }, + }, + }, + }) + assert.Equal(t, "api://AzureADTokenExchange", got) + }) +} + +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{ + OTLP: &OTLPConfig{ + GitHubApp: &OTLPGitHubAppConfig{}, + }, + }, + }, nil)) + + assert.True(t, hasOTLPGitHubOIDCAuth(nil, map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{}, + }, + }, + })) + + assert.False(t, hasOTLPGitHubOIDCAuth(nil, map[string]any{ + "observability": map[string]any{ + "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 }}", + }, + }, + }, + })) +} + +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{} } @@ -1685,6 +1830,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/permissions_compiler_validator.go b/pkg/workflow/permissions_compiler_validator.go index 350cd5a0bd9..01070df5533 100644 --- a/pkg/workflow/permissions_compiler_validator.go +++ b/pkg/workflow/permissions_compiler_validator.go @@ -147,8 +147,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) } @@ -165,21 +165,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" + } + + 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 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))) } diff --git a/pkg/workflow/setup_step_version_test.go b/pkg/workflow/setup_step_version_test.go index e139a936c3b..acc07004f68 100644 --- a/pkg/workflow/setup_step_version_test.go +++ b/pkg/workflow/setup_step_version_test.go @@ -269,3 +269,89 @@ 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{ + "github-app": map[string]any{ + "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 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) + } + 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) { + c := NewCompiler() + c.SetActionMode(ActionModeScript) + data := &WorkflowData{ + Name: "my-workflow", + RawFrontmatter: map[string]any{ + "observability": map[string]any{ + "otlp": map[string]any{ + "github-app": map[string]any{}, + }, + }, + }, + } + + 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) + } +} diff --git a/pkg/workflow/top_level_github_app_integration_test.go b/pkg/workflow/top_level_github_app_integration_test.go index 187efcf9924..61294d0bc85 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,9 +326,20 @@ 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 }}", }, }, + { + name: "otlp github-app workflow file", + workflowFile: "../cli/workflows/test-top-level-github-app-otlp.md", + expectContains: []string{ + "id: mint-otlp-oidc-token", + "uses: actions/create-github-app-token", + "client-id: ${{ vars.APP_ID }}", + "private-key: ${{ secrets.APP_PRIVATE_KEY }}", + }, + }, } for _, tt := range tests { @@ -353,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 }}") +}