Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6ada218
Plan OTLP OIDC minting support
Copilot May 26, 2026
32c3594
Add OTLP github-oidc pre-setup minting wiring
Copilot May 26, 2026
e77321d
Format action_setup_otlp after OIDC changes
Copilot May 26, 2026
97c83f7
Rename OTLP auth key to github-app
Copilot May 27, 2026
4d286c7
Align OTLP typed field with github-app key
Copilot May 27, 2026
f2b547b
Fix OTLP OIDC header propagation and permission validation
Copilot May 27, 2026
33994c6
Refine OTLP endpoint header merge helper
Copilot May 27, 2026
fc85da6
Simplify OTLP github-app OIDC frontmatter
Copilot May 27, 2026
0dbd3c7
Add OTLP github-app helper coverage
Copilot May 27, 2026
018bc02
Preserve OTLP github-app info during observability merge
Copilot May 27, 2026
c5e5236
Strengthen parsed-frontmatter OTLP mint step assertion
Copilot May 27, 2026
320a500
test: assert workflow fixtures emit create-github-app-token action
Copilot May 27, 2026
c60b105
Merge branch 'main' into copilot/add-github-app-oidc-token-support
pelikhan May 27, 2026
28c0543
test: add OTLP github-app workflow fixture integration coverage
Copilot May 27, 2026
1cf8d45
Merge branch 'main' into copilot/add-github-app-oidc-token-support
github-actions[bot] May 27, 2026
f529e28
Support OTLP github-app credential sample and token minting
Copilot May 27, 2026
216e475
Refine OTLP github-app mint-step generation and tests
Copilot May 27, 2026
bda3c0c
Tighten OTLP GitHub App integration assertions
Copilot May 27, 2026
f2c6376
Clean up OTLP GitHub App helper nil handling
Copilot May 27, 2026
fe961a3
Merge branch 'main' into copilot/add-github-app-oidc-token-support
pelikhan May 27, 2026
a302629
Remove legacy OTLP github-oidc type syntax
Copilot May 27, 2026
1b53d4d
Clarify implicit OTLP OIDC wording
Copilot May 27, 2026
a450fe4
Tighten legacy OTLP type rejection test
Copilot May 27, 2026
8ec1253
Harden OTLP legacy schema error assertion
Copilot May 27, 2026
d6b640b
Simplify legacy OTLP type error assertion
Copilot May 27, 2026
1208417
Simplify OTLP github-app schema shape
Copilot May 27, 2026
81dd667
Refine OTLP github-app schema validation tests
Copilot May 27, 2026
7fca136
Simplify OTLP github-app schema fields
Copilot May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/aw/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,13 +290,20 @@ 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 <token>`). Injected as `OTEL_EXPORTER_OTLP_HEADERS`. Supports GitHub Actions expressions.
- Example:

```yaml
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 }}
```

Expand Down
4 changes: 4 additions & 0 deletions actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions actions/setup/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions actions/setup/js/action_setup_otlp.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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");
}
}
Comment on lines +130 to +144

if (!endpoints) {
console.log("[otlp] GH_AW_OTLP_ENDPOINTS not set, skipping setup span");
} else {
Expand Down
47 changes: 47 additions & 0 deletions actions/setup/js/action_setup_otlp.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
});
Expand Down Expand Up @@ -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);
});
});
Comment on lines +156 to +195

describe("SETUP_START_MS propagation", () => {
it("should pass startMs=0 when SETUP_START_MS is not set", async () => {
await run();
Expand Down
28 changes: 28 additions & 0 deletions pkg/cli/workflows/test-top-level-github-app-otlp.md
Original file line number Diff line number Diff line change
@@ -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`.
108 changes: 108 additions & 0 deletions pkg/parser/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
23 changes: 23 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
Loading