diff --git a/docs/adr/0002-explicit-opt-in-allow-workflows-permission.md b/docs/adr/0002-explicit-opt-in-allow-workflows-permission.md new file mode 100644 index 0000000000..e5e2bcc1cf --- /dev/null +++ b/docs/adr/0002-explicit-opt-in-allow-workflows-permission.md @@ -0,0 +1,80 @@ +# ADR-0002: Explicit Opt-In for GitHub App workflows:write Permission via allow-workflows Field + +**Date**: 2026-04-11 +**Status**: Draft +**Deciders**: pelikhan, Copilot + +--- + +## Part 1 — Narrative (Human-Friendly) + +### Context + +The gh-aw safe-outputs system mints scoped GitHub App tokens to push changes to pull request branches and create pull requests. When the `allowed-files` configuration targets `.github/workflows/` paths, GitHub requires the `workflows:write` permission on the minted token — a permission only available to GitHub Apps, not to `GITHUB_TOKEN`. The compiler's `ComputePermissionsForSafeOutputs` function previously had no knowledge of this requirement, leaving users unable to push workflow files through safe-outputs without resorting to fragile post-compile `sed` injection as a workaround. The question was: should the compiler auto-infer `workflows:write` from `allowed-files` patterns, or require an explicit opt-in field? + +### Decision + +We will add an explicit boolean field `allow-workflows` on both `create-pull-request` and `push-to-pull-request-branch` safe-outputs configurations. When set to `true`, the compiler adds `workflows: write` to the GitHub App token permissions computed by `ComputePermissionsForSafeOutputs`. The field is intentionally not auto-inferred from `allowed-files` patterns, keeping the elevated permission visible and auditable in the workflow source. Compile-time validation enforces that `safe-outputs.github-app` is configured (with non-empty `app-id` and `private-key`) whenever `allow-workflows: true` is present, because `workflows:write` cannot be granted via `GITHUB_TOKEN`. + +### Alternatives Considered + +#### Alternative 1: Auto-infer workflows:write from allowed-files patterns + +Detect at compile time whether any `allowed-files` pattern matches a `.github/workflows/` path (e.g., `strings.HasPrefix(pattern, ".github/workflows/")`), and automatically add `workflows: write` to the token permissions when such a pattern is found. This eliminates a configuration step for the user. It was rejected because it makes an elevated, GitHub App-only permission appear silently: a reviewer reading a workflow file would have no indication that `workflows:write` is being requested unless they also inspected the `allowed-files` list and understood the compiler's inference rules. Explicit opt-in makes the elevated permission a first-class, auditable fact in the workflow source. + +#### Alternative 2: Grant workflows:write globally for all safe-outputs operations + +Always include `workflows: write` in the safe-outputs GitHub App token, regardless of whether any workflow files are being pushed. This simplifies the permission model by eliminating per-handler configuration. It was rejected because it violates the principle of least privilege: the vast majority of safe-outputs deployments do not push workflow files, and granting `workflows:write` to those tokens unnecessarily expands the blast radius of a token compromise. Scoped permissions per handler are a deliberate security property of the safe-outputs system. + +#### Alternative 3: Keep the post-compile sed-injection workaround as the documented path + +Document the existing workaround (injecting `permission-workflows: write` via `sed` in a post-compile step) as the official solution for users who need to push workflow files. This requires no compiler changes. It was rejected because sed injection is inherently fragile, version-sensitive, and bypasses compile-time safety checks. It also produces compiled output that diverges from what the compiler would generate from the source, breaking the compiler's reproducibility guarantee. + +### Consequences + +#### Positive +- The elevated `workflows:write` permission is explicit and visible in the workflow source — security reviewers can see it at a glance without needing to understand compiler inference rules. +- Compile-time validation prevents misconfiguration: a workflow with `allow-workflows: true` but no GitHub App configured fails at compile time with a clear error message, rather than silently generating a broken workflow. +- The implementation is consistent with the existing staged-mode pattern: `allow-workflows: true` has no effect when the handler is in staged mode, since staged mode does not mint real tokens. +- Eliminates the fragile post-compile `sed` workaround that was the only previous path for pushing workflow files. + +#### Negative +- Users who need to push workflow files must add an extra field (`allow-workflows: true`) to their configuration, even when the need is obvious from their `allowed-files` patterns. This is a deliberate UX trade-off for auditability. +- The `validateSafeOutputsAllowWorkflows` validation function must be called explicitly from `validateWorkflowData` in `compiler.go`. If future validation functions are not wired up in the same way, they will be silently skipped. + +#### Neutral +- The `allow-workflows` field is defined separately on each handler (`create-pull-request` and `push-to-pull-request-branch`) rather than as a single top-level safe-outputs flag. This allows per-handler control but means users targeting both handlers must set the field in both places. +- The JSON schema is updated for both handler schemas, keeping schema-based tooling (IDE validation, linting) in sync with the Go implementation. + +--- + +## Part 2 — Normative Specification (RFC 2119) + +> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +### Permission Computation + +1. Implementations **MUST** add `workflows: write` to the computed GitHub App token permissions for a safe-outputs handler if and only if that handler's `allow-workflows` field is `true` and the handler is not in staged mode. +2. Implementations **MUST NOT** add `workflows: write` to token permissions when `allow-workflows` is absent or `false`. +3. Implementations **MUST NOT** add `workflows: write` to token permissions when the handler is in staged mode (i.e., `Staged: true`), even if `allow-workflows: true` is set. +4. Implementations **MUST NOT** infer the need for `workflows: write` from `allowed-files` patterns or any other implicit signal — the only authoritative source is the explicit `allow-workflows` field. + +### Compile-Time Validation + +1. Implementations **MUST** validate at compile time that `safe-outputs.github-app` is configured with a non-empty `app-id` and non-empty `private-key` whenever any safe-outputs handler has `allow-workflows: true`. +2. Implementations **MUST** produce a compile error if `allow-workflows: true` is present without a valid GitHub App configuration. +3. Implementations **SHOULD** include the handler name(s) in the compile error message to help users identify which handler(s) triggered the validation failure. +4. Implementations **SHOULD** include a configuration example in the compile error message showing how to add a GitHub App configuration. + +### Schema and Documentation + +1. Implementations **MUST** declare `allow-workflows` as an optional boolean field (default: `false`) in the JSON schema for both the `create-pull-request` and `push-to-pull-request-branch` handler objects. +2. Implementations **SHOULD** document that `allow-workflows` requires a GitHub App and cannot be used with `GITHUB_TOKEN`. + +### Conformance + +An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Specifically: the `workflows: write` permission is added to GitHub App token permissions when and only when an active (non-staged) safe-outputs handler has `allow-workflows: true`, and compilation fails with a clear error when `allow-workflows: true` is set without a valid GitHub App configuration. Failure to meet any **MUST** or **MUST NOT** requirement constitutes non-conformance. + +--- + +*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/24280835716) workflow. The PR author must review, complete, and finalize this document before the PR can merge.* diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 6818b18c7f..fcd254436d 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -57,16 +57,19 @@ metadata: # Workflow specifications to import. Supports array form (list of paths) or object # form with 'aw' (agentic workflow paths) and 'apm-packages' (APM packages) -# subfields. +# subfields. Path resolution: (1) relative paths (e.g., 'shared/file.md') are +# resolved relative to the workflow's directory; (2) paths starting with +# '.github/' or '/' are resolved from the repository root (repo-root-relative); +# (3) paths matching 'owner/repo/path@ref' are fetched from GitHub at compile time +# (cross-repo). # (optional) # This field supports multiple formats (oneOf): -# Option 1: Array of workflow specifications to import (similar to @include -# directives but defined in frontmatter). Format: owner/repo/path@ref (e.g., -# githubnext/agentics/workflows/shared/common.md@v1.0.0). Can be strings or -# objects with path and inputs. Any markdown files under .github/agents directory -# are treated as custom agent files and only one agent file is allowed per -# workflow. +# Option 1: Array of workflow specifications to import. Three path formats are +# supported: relative paths ('shared/file.md'), repo-root-relative paths +# ('.github/agents/my-agent.md'), and cross-repo paths ('owner/repo/path@ref'). +# Any markdown files under .github/agents directory are treated as custom agent +# files and only one agent file is allowed per workflow. imports: [] # Array items: undefined @@ -1023,9 +1026,9 @@ on: # Whether to post status comments (started/completed) on the triggering item. When # true, adds a comment with workflow run link and updates it on completion. When - # false or not specified, no status comments are posted. Must be explicitly set to - # true to enable status comments - there is no automatic bundling with - # ai-reaction. + # false or not specified, no status comments are posted. Automatically enabled for + # slash_command and label_command triggers — manual configuration is only needed + # for other trigger types. # (optional) status-comment: true @@ -1374,7 +1377,8 @@ runs-on-slim: "example-value" # Workflow timeout in minutes (GitHub Actions standard field). Defaults to 20 # minutes for agentic workflows. Has sensible defaults and can typically be -# omitted. Supports GitHub Actions expressions (e.g. '${{ inputs.timeout }}') for +# omitted. Custom runners support longer timeouts beyond the GitHub-hosted runner +# limit. Supports GitHub Actions expressions (e.g. '${{ inputs.timeout }}') for # reusable workflow_call workflows. # (optional) # This field supports multiple formats (oneOf): @@ -1631,7 +1635,7 @@ sandbox: # Array of Mount specification in format 'source:destination:mode' # Memory limit for the AWF container (e.g., '4g', '8g'). Passed as --memory-limit - # to AWF. If not specified, AWF's default memory limit of 6g is used. + # to AWF. If not specified, AWF's default memory limit is used. # (optional) memory: "example-value" @@ -1735,6 +1739,13 @@ sandbox: # (optional) domain: "localhost" + # Keepalive ping interval in seconds for HTTP MCP backends. Sends periodic pings + # to prevent session expiry during long-running agent tasks. Set to -1 to disable + # keepalive pings. Unset or 0 uses the gateway default (1500 seconds = 25 + # minutes). + # (optional) + keepalive-interval: 1 + # Conditional execution expression # (optional) if: "example-value" @@ -1751,6 +1762,22 @@ steps: steps: [] # Array items: undefined +# Custom workflow steps to run at the very beginning of the agent job, before +# checkout and any other built-in steps. Use pre-steps to mint short-lived tokens +# or perform any setup that must happen before the repository is checked out. Step +# outputs are available via ${{ steps..outputs. }} and can be referenced +# in checkout.token to avoid masked-value cross-job-boundary issues. +# (optional) +# This field supports multiple formats (oneOf): + +# Option 1: object +pre-steps: + {} + +# Option 2: array +pre-steps: [] + # Array items: undefined + # Custom workflow steps to run after AI execution # (optional) # This field supports multiple formats (oneOf): @@ -1867,34 +1894,57 @@ engine: # (optional) api-target: "example-value" - # Optional array of command-line arguments to pass to the AI engine CLI. These - # arguments are injected after all other args but before the prompt. - # (optional) - args: [] - # Array of strings - - # Custom model token weights for effective token computation. Overrides or - # extends the built-in model multipliers from model_multipliers.json. Useful - # for custom models or adjusted cost ratios. + # Custom model token weights for effective token computation. Overrides or extends + # the built-in model multipliers from model_multipliers.json. Useful for custom + # models or adjusted cost ratios. # (optional) token-weights: - # Per-model cost multipliers relative to the reference model - # (claude-sonnet-4.5 = 1.0). Keys are model names (case-insensitive, - # prefix matching supported). + # Per-model cost multipliers relative to the reference model (claude-sonnet-4.5 = + # 1.0). Keys are model names (case-insensitive, prefix matching supported). Values + # are numeric multipliers. # (optional) multipliers: - my-custom-model: 2.5 + {} - # Per-token-class weights applied before the model multiplier. Defaults: - # input: 1.0, cached-input: 0.1, output: 4.0, reasoning: 4.0, - # cache-write: 1.0 + # Per-token-class weights applied before the model multiplier. Any specified + # weight overrides the corresponding default. # (optional) token-class-weights: - input: 1.0 - cached-input: 0.1 - output: 4.0 - reasoning: 4.0 - cache-write: 1.0 + # Weight for input tokens (default: 1.0) + # (optional) + input: 1 + + # Weight for cached input tokens (default: 0.1) + # (optional) + cached-input: 1 + + # Weight for output tokens (default: 4.0) + # (optional) + output: 1 + + # Weight for reasoning tokens (default: 4.0) + # (optional) + reasoning: 1 + + # Weight for cache write tokens (default: 1.0) + # (optional) + cache-write: 1 + + # Optional array of command-line arguments to pass to the AI engine CLI. These + # arguments are injected after all other args but before the prompt. + # (optional) + args: [] + # Array of strings + + # When true, disables automatic loading of context and custom instructions by the + # AI engine. The engine-specific flag depends on the engine: copilot uses + # --no-custom-instructions (suppresses .github/AGENTS.md and user-level custom + # instructions), claude uses --bare (suppresses CLAUDE.md memory files), codex + # uses --no-system-prompt (suppresses the default system prompt), gemini sets + # GEMINI_SYSTEM_MD=/dev/null (overrides the built-in system prompt with an empty + # one). Defaults to false. + # (optional) + bare: true # Option 3: Inline engine definition: specifies a runtime adapter and optional # provider settings directly in the workflow frontmatter, without requiring a @@ -1975,6 +2025,13 @@ engine: body-inject: {} + # When true, disables automatic loading of context and custom instructions by the + # AI engine. The engine-specific flag depends on the engine: copilot uses + # --no-custom-instructions, claude uses --bare, codex uses --no-system-prompt, + # gemini sets GEMINI_SYSTEM_MD=/dev/null. Defaults to false. + # (optional) + bare: true + # Option 4: Engine definition: full declarative metadata for a named engine entry # (used in builtin engine shared workflow files such as @builtin:engines/*.md) engine: @@ -2499,43 +2556,6 @@ tools: # Option 2: Enable agentic-workflows tool with default settings (same as true) agentic-workflows: null - # qmd documentation search tool (https://github.com/tobi/qmd). Builds a local - # vector search index in a dedicated indexing job and shares it with the agent job - # via GitHub Actions cache. The agent job mounts a search MCP server over the - # pre-built index and does not need contents:read permission. - # (optional) - qmd: - # List of named documentation collections built from checked-out repositories. - # Each entry can optionally specify its own checkout configuration to target a - # different repository. - # (optional) - checkouts: [] - - # List of GitHub search queries whose results are downloaded and added to the qmd - # index. - # (optional) - searches: [] - - # GitHub Actions cache key used to persist the qmd index across workflow runs. - # When set without any indexing sources (checkouts/searches), qmd operates in - # read-only mode: the index is restored from cache and all indexing steps are - # skipped. - # (optional) - cache-key: "example-value" - - # Enable GPU acceleration for the embedding model (node-llama-cpp). Defaults to - # false: NODE_LLAMA_CPP_GPU=false is injected into the indexing step so GPU - # probing is skipped on CPU-only runners. Set to true only when the indexing - # runner has a GPU. - # (optional) - gpu: true - - # Override the runner image for the qmd indexing job. Defaults to the same runner - # as the agent job. Use this when the indexing job requires a different runner - # (e.g. a GPU runner). - # (optional) - runs-on: "example-value" - # Cache memory MCP configuration for persistent memory storage # (optional) # This field supports multiple formats (oneOf): @@ -3694,22 +3714,21 @@ safe-outputs: # Array of strings # Controls whether the workflow requests discussions:write permission for - # add-comment and includes discussions in the event trigger condition. Default: - # true (includes discussions:write). Set to false if your GitHub App lacks - # Discussions permission to prevent 422 errors during token generation. + # add-comment. Default: true (includes discussions:write). Set to false if your + # GitHub App lacks Discussions permission to prevent 422 errors during token + # generation. # (optional) discussions: true - # Controls whether the workflow requests issues:write permission for add-comment - # and includes issues in the event trigger condition. Default: true (includes - # issues:write). Set to false to disable issue commenting. + # Controls whether the workflow requests issues:write permission for add-comment. + # Default: true (includes issues:write). Set to false to disable issue commenting + # permissions. # (optional) issues: true # Controls whether the workflow requests pull-requests:write permission for - # add-comment and includes pull requests in the event trigger condition. Default: - # true (includes pull-requests:write). Set to false to disable pull request - # commenting. + # add-comment. Default: true (includes pull-requests:write). Set to false to + # disable pull request commenting permissions. # (optional) pull-requests: true @@ -3785,6 +3804,24 @@ safe-outputs: reviewers: [] # Array items: string + # Optional assignee(s) for a fallback issue created when pull request creation + # cannot proceed, including protected-files fallback-to-issue and pull request + # creation or push failures. Accepts either a single string or an array of + # usernames. + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Single username to assign to a fallback issue created when pull + # request creation cannot proceed, including protected-files fallback-to-issue and + # pull request creation or push failures. + assignees: "example-value" + + # Option 2: List of usernames to assign to a fallback issue created when pull + # request creation cannot proceed, including protected-files fallback-to-issue and + # pull request creation or push failures. + assignees: [] + # Array items: string + # Whether to create pull request as draft (defaults to true). Accepts a boolean or # a GitHub Actions expression. # (optional) @@ -3925,6 +3962,13 @@ safe-outputs: # (optional) staged: true + # When true, adds workflows: write to the GitHub App token permissions. Required + # when allowed-files targets .github/workflows/ paths. Requires + # safe-outputs.github-app to be configured because the workflows permission is a + # GitHub App-only permission and cannot be granted via GITHUB_TOKEN. + # (optional) + allow-workflows: true + # Option 2: Enable pull request creation with default configuration create-pull-request: null @@ -4041,10 +4085,10 @@ safe-outputs: # Optional list of allowed review event types. If omitted, all event types # (APPROVE, COMMENT, REQUEST_CHANGES) are allowed. Use this to restrict the agent - # to specific event types at the infrastructure level. + # to specific event types, e.g. [COMMENT, REQUEST_CHANGES] to prevent approvals. # (optional) allowed-events: [] - # Array of strings (APPROVE, COMMENT, REQUEST_CHANGES) + # Array of strings # GitHub token to use for this specific output type. Overrides global github-token # if specified. @@ -4979,6 +5023,13 @@ safe-outputs: # (optional) patch-format: "am" + # When true, adds workflows: write to the GitHub App token permissions. Required + # when allowed-files targets .github/workflows/ paths. Requires + # safe-outputs.github-app to be configured because the workflows permission is a + # GitHub App-only permission and cannot be granted via GITHUB_TOKEN. + # (optional) + allow-workflows: true + # Enable AI agents to minimize (hide) comments on issues or pull requests based on # relevance, spam detection, or moderation rules. # (optional) @@ -5364,6 +5415,86 @@ safe-outputs: # Option 2: Enable asset publishing with default configuration upload-asset: null + # Enable AI agents to upload files as run-scoped GitHub Actions artifacts. Returns + # a temporary artifact ID rather than a raw download URL, keeping authorization + # centralized. + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Configuration for uploading files as run-scoped GitHub Actions + # artifacts + upload-artifact: + # Maximum number of upload_artifact tool calls allowed per run (default: 1) + # (optional) + max-uploads: 1 + + # Artifact retention period in days (fixed; the agent cannot override this value). + # Supports integer or GitHub Actions expression (e.g. '${{ inputs.retention-days + # }}'). + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: integer + retention-days: 1 + + # Option 2: string + retention-days: "example-value" + + # Upload files directly without zip archiving (fixed; the agent cannot override + # this value). Only valid for single-file uploads. Supports boolean or GitHub + # Actions expression (e.g. '${{ inputs.skip-archive }}'). + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: boolean + skip-archive: true + + # Option 2: string + skip-archive: "example-value" + + # Maximum total upload size in bytes per slot (default: 104857600 = 100 MB) + # (optional) + max-size-bytes: 1 + + # Glob patterns restricting which paths relative to the staging directory the + # model may upload + # (optional) + allowed-paths: [] + # Array of strings + + # Default include/exclude glob filters applied on top of allowed-paths + # (optional) + filters: + # Glob patterns for files to include + # (optional) + include: [] + # Array of strings + + # Glob patterns for files to exclude + # (optional) + exclude: [] + # Array of strings + + # Default values injected when the model omits a field + # (optional) + defaults: + # Behaviour when no files match: 'error' (default) or 'ignore' + # (optional) + if-no-files: "error" + + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + + # If true, emit step summary messages instead of making GitHub Actions artifact + # uploads (preview mode) + # (optional) + staged: true + + # Option 2: Enable artifact uploads with default configuration + upload-artifact: null + # Enable AI agents to edit and update GitHub release content, including release # notes, assets, and metadata. # (optional) @@ -5902,6 +6033,57 @@ safe-outputs: actions: {} + # Enable AI agents to signal that a task could not be completed due to + # infrastructure or tool failures (e.g., MCP crash, missing auth, inaccessible + # repository). Activates failure handling even when the agent exits 0. + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: Configuration for report_incomplete safe output + report-incomplete: + # Maximum number of report_incomplete signals (default: 5). Supports integer or + # GitHub Actions expression (e.g. '${{ inputs.max }}'). + # (optional) + # This field supports multiple formats (oneOf): + + # Option 1: integer + max: 1 + + # Option 2: GitHub Actions expression that resolves to an integer at runtime + max: "example-value" + + # Whether to create or update GitHub issues when the task was incomplete (default: + # true) + # (optional) + create-issue: true + + # Prefix for issue titles when creating issues for incomplete runs (default: + # '[incomplete]') + # (optional) + title-prefix: "example-value" + + # Labels to add to created issues for incomplete runs + # (optional) + labels: [] + # Array of strings + + # GitHub token to use for this specific output type. Overrides global github-token + # if specified. + # (optional) + github-token: "${{ secrets.GITHUB_TOKEN }}" + + # If true, emit step summary messages instead of making GitHub API calls for this + # specific output type (preview mode) + # (optional) + staged: true + + # Option 2: Enable report_incomplete with default configuration + report-incomplete: null + + # Option 3: Explicitly disable report_incomplete (false). report_incomplete is + # enabled by default when safe-outputs is configured. + report-incomplete: true + # Configuration for secret redaction behavior in workflow outputs and artifacts # (optional) secret-masking: @@ -5912,7 +6094,23 @@ secret-masking: # Optional observability output settings for workflow runs. # (optional) -observability: {} +observability: + # OTLP (OpenTelemetry Protocol) trace export configuration. + # (optional) + otlp: + # OTLP collector endpoint URL (e.g. 'https://traces.example.com:4317'). Supports + # GitHub Actions expressions such as ${{ secrets.OTLP_ENDPOINT }}. When a static + # URL is provided, its hostname is automatically added to the network firewall + # allowlist. + # (optional) + endpoint: "example-value" + + # Comma-separated list of key=value HTTP headers to include with every OTLP export + # request (e.g. 'Authorization=Bearer '). Supports GitHub Actions + # expressions such as ${{ secrets.OTLP_HEADERS }}. Injected as the + # OTEL_EXPORTER_OTLP_HEADERS environment variable. + # (optional) + headers: "example-value" # Allow list of bot identifiers that can trigger the workflow even if they don't # meet the required role permissions. When the actor is in this list, the bot must @@ -5988,6 +6186,17 @@ private: true # (optional) check-for-updates: true +# Allow npm pre/post install scripts to execute during package installation. By +# default, --ignore-scripts is added to all generated npm install commands to +# prevent supply chain attacks via malicious install hooks. Setting +# run-install-scripts: true disables this protection globally (all runtimes). A +# supply chain security warning is emitted at compile time; in strict mode this is +# an error. Per-runtime control is also available via +# runtimes..run-install-scripts. See: +# https://github.github.com/gh-aw/reference/frontmatter/#run-install-scripts +# (optional) +run-install-scripts: true + # MCP Scripts configuration for defining custom lightweight MCP tools as # JavaScript, shell scripts, or Python scripts. Tools are mounted in an MCP server # and have access to secrets specified by the user. Only one of 'script' diff --git a/docs/src/content/docs/reference/safe-outputs-pull-requests.md b/docs/src/content/docs/reference/safe-outputs-pull-requests.md index 07643a2b53..494646ed9c 100644 --- a/docs/src/content/docs/reference/safe-outputs-pull-requests.md +++ b/docs/src/content/docs/reference/safe-outputs-pull-requests.md @@ -208,6 +208,27 @@ Patterns support `*` (any characters except `/`) and `**` (any characters includ > [!WARNING] > `allowed-files` should enumerate exactly the files the workflow legitimately manages. Overly broad patterns (e.g., `**`) disable all protection. +### Allowing Workflow File Changes with `allow-workflows` + +When `allowed-files` targets `.github/workflows/` paths, pushing to those paths requires the GitHub Actions `workflows` permission. This is a **GitHub App-only permission** — it cannot be granted via `GITHUB_TOKEN`. + +Set `allow-workflows: true` on `create-pull-request` or `push-to-pull-request-branch` to add `workflows: write` to the minted GitHub App token. A `safe-outputs.github-app` configuration is required; the compiler will error if `allow-workflows: true` is set without one. + +```yaml wrap +safe-outputs: + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + create-pull-request: + allow-workflows: true + allowed-files: + - ".github/workflows/*.lock.yml" + protected-files: allowed +``` + +> [!NOTE] +> `allow-workflows` is intentionally explicit rather than auto-inferred from `allowed-files` patterns. This makes the elevated permission visible and auditable in the workflow source. + ### Protected Files Protection covers three categories: diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index f89395c29e..068a704c6a 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -609,7 +609,7 @@ { "type": "array", "minItems": 1, - "description": "Array of label names \u2014 any of these labels will trigger the workflow.", + "description": "Array of label names — any of these labels will trigger the workflow.", "items": { "type": "string", "minLength": 1, @@ -630,7 +630,7 @@ { "type": "array", "minItems": 1, - "description": "Array of label names \u2014 any of these labels will trigger the workflow.", + "description": "Array of label names — any of these labels will trigger the workflow.", "items": { "type": "string", "minLength": 1, @@ -1762,7 +1762,7 @@ "oneOf": [ { "type": "null", - "description": "Bare key with no value \u2014 equivalent to true. Skips workflow execution if any CI checks on the target branch are currently failing." + "description": "Bare key with no value — equivalent to true. Skips workflow execution if any CI checks on the target branch are currently failing." }, { "type": "boolean", @@ -1836,12 +1836,12 @@ "description": "Skip workflow execution for specific GitHub users. Useful for preventing workflows from running for specific accounts (e.g., bots, specific team members)." }, "roles": { - "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (\u26a0\ufe0f security consideration).", + "description": "Repository access roles required to trigger agentic workflows. Defaults to ['admin', 'maintainer', 'write'] for security. Use 'all' to allow any authenticated user (⚠️ security consideration).", "oneOf": [ { "type": "string", "enum": ["all"], - "description": "Allow any authenticated user to trigger the workflow (\u26a0\ufe0f disables permission checking entirely - use with caution)" + "description": "Allow any authenticated user to trigger the workflow (⚠️ disables permission checking entirely - use with caution)" }, { "type": "array", @@ -1887,7 +1887,7 @@ }, "status-comment": { "type": "boolean", - "description": "Whether to post status comments (started/completed) on the triggering item. When true, adds a comment with workflow run link and updates it on completion. When false or not specified, no status comments are posted. Automatically enabled for slash_command and label_command triggers \u2014 manual configuration is only needed for other trigger types.", + "description": "Whether to post status comments (started/completed) on the triggering item. When true, adds a comment with workflow run link and updates it on completion. When false or not specified, no status comments are posted. Automatically enabled for slash_command and label_command triggers — manual configuration is only needed for other trigger types.", "examples": [true, false] }, "github-token": { @@ -2863,7 +2863,7 @@ }, "network": { "$comment": "Strict mode requirements: When strict=true, the 'network' field must be present (not null/undefined) and cannot contain standalone wildcard '*' in allowed domains (but patterns like '*.example.com' ARE allowed). This is validated in Go code (pkg/workflow/strict_mode_validation.go) via validateStrictNetwork().", - "description": "Network access control for AI engines using ecosystem identifiers and domain allowlists. Supports wildcard patterns like '*.example.com' to match any subdomain. Controls web fetch and search capabilities. IMPORTANT: For workflows that build/install/test code, always include the language ecosystem identifier alongside 'defaults' \u2014 'defaults' alone only covers basic infrastructure, not package registries. Key ecosystem identifiers by runtime: 'dotnet' (.NET/NuGet), 'python' (pip/PyPI), 'node' (npm/yarn), 'go' (go modules), 'java' (Maven/Gradle), 'ruby' (Bundler), 'rust' (Cargo), 'swift' (Swift PM). Example: a .NET project needs network: { allowed: [defaults, dotnet] }.", + "description": "Network access control for AI engines using ecosystem identifiers and domain allowlists. Supports wildcard patterns like '*.example.com' to match any subdomain. Controls web fetch and search capabilities. IMPORTANT: For workflows that build/install/test code, always include the language ecosystem identifier alongside 'defaults' — 'defaults' alone only covers basic infrastructure, not package registries. Key ecosystem identifiers by runtime: 'dotnet' (.NET/NuGet), 'python' (pip/PyPI), 'node' (npm/yarn), 'go' (go modules), 'java' (Maven/Gradle), 'ruby' (Bundler), 'rust' (Cargo), 'swift' (Swift PM). Example: a .NET project needs network: { allowed: [defaults, dotnet] }.", "examples": [ "defaults", { @@ -3376,7 +3376,7 @@ [ { "name": "Verify Post-Steps Execution", - "run": "echo \"\u2705 Post-steps are executing correctly\"\necho \"This step runs after the AI agent completes\"\n" + "run": "echo \"✅ Post-steps are executing correctly\"\necho \"This step runs after the AI agent completes\"\n" }, { "name": "Upload Test Results", @@ -5743,7 +5743,7 @@ "items": { "type": "string" }, - "description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern \u2014 files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)." + "description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern — files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)." }, "preserve-branch-name": { "type": "boolean", @@ -5767,6 +5767,12 @@ "type": "boolean", "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] + }, + "allow-workflows": { + "type": "boolean", + "description": "When true, adds workflows: write to the GitHub App token permissions. Required when allowed-files targets .github/workflows/ paths. Requires safe-outputs.github-app to be configured because the workflows permission is a GitHub App-only permission and cannot be granted via GITHUB_TOKEN.", + "default": false, + "examples": [true, false] } }, "additionalProperties": false, @@ -5993,7 +5999,7 @@ "oneOf": [ { "type": "object", - "description": "Configuration for resolving review threads on pull requests. Resolution is scoped to the triggering PR only \u2014 threads on other PRs cannot be resolved.", + "description": "Configuration for resolving review threads on pull requests. Resolution is scoped to the triggering PR only — threads on other PRs cannot be resolved.", "properties": { "max": { "description": "Maximum number of review threads to resolve (default: 10) Supports integer or GitHub Actions expression (e.g. '${{ inputs.max }}').", @@ -6907,7 +6913,7 @@ "items": { "type": "string" }, - "description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern \u2014 files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)." + "description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern — files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)." }, "excluded-files": { "type": "array", @@ -6921,6 +6927,12 @@ "enum": ["am", "bundle"], "default": "am", "description": "Transport format for packaging changes. \"am\" (default) uses git format-patch/git am. \"bundle\" uses git bundle, which preserves merge commit topology, per-commit authorship, and merge-resolution-only content." + }, + "allow-workflows": { + "type": "boolean", + "description": "When true, adds workflows: write to the GitHub App token permissions. Required when allowed-files targets .github/workflows/ paths. Requires safe-outputs.github-app to be configured because the workflows permission is a GitHub App-only permission and cannot be granted via GITHUB_TOKEN.", + "default": false, + "examples": [true, false] } }, "additionalProperties": false @@ -7891,7 +7903,7 @@ }, "scripts": { "type": "object", - "description": "Inline JavaScript script handlers that run inside the consolidated safe-outputs job handler loop. Unlike 'jobs' (which create separate GitHub Actions jobs), scripts execute in-process alongside the built-in handlers. Users write only the body of the main function \u2014 the compiler wraps it with 'async function main(config = {}) { ... }' and 'module.exports = { main };' automatically. Script names containing dashes will be automatically normalized to underscores (e.g., 'post-slack-message' becomes 'post_slack_message').", + "description": "Inline JavaScript script handlers that run inside the consolidated safe-outputs job handler loop. Unlike 'jobs' (which create separate GitHub Actions jobs), scripts execute in-process alongside the built-in handlers. Users write only the body of the main function — the compiler wraps it with 'async function main(config = {}) { ... }' and 'module.exports = { main };' automatically. Script names containing dashes will be automatically normalized to underscores (e.g., 'post-slack-message' becomes 'post_slack_message').", "patternProperties": { "^[a-zA-Z_][a-zA-Z0-9_-]*$": { "type": "object", @@ -7949,7 +7961,7 @@ }, "script": { "type": "string", - "description": "JavaScript handler body. Write only the code that runs inside the handler for each item \u2014 the compiler generates the full outer wrapper including config input destructuring (`const { channel, message } = config;`) and the handler function (`return async function handleX(item, resolvedTemporaryIds) { ... }`). The body has access to `item` (runtime message with input values), `resolvedTemporaryIds` (map of temporary IDs), and config-destructured local variables for each declared input." + "description": "JavaScript handler body. Write only the code that runs inside the handler for each item — the compiler generates the full outer wrapper including config input destructuring (`const { channel, message } = config;`) and the handler function (`return async function handleX(item, resolvedTemporaryIds) { ... }`). The body has access to `item` (runtime message with input values), `resolvedTemporaryIds` (map of temporary IDs), and config-destructured local variables for each declared input." } }, "required": ["script"], @@ -7984,8 +7996,8 @@ }, "staged-title": { "type": "string", - "description": "Custom title template for staged mode preview. Available placeholders: {operation}. Example: '\ud83c\udfad Preview: {operation}'", - "examples": ["\ud83c\udfad Preview: {operation}", "## Staged Mode: {operation}"] + "description": "Custom title template for staged mode preview. Available placeholders: {operation}. Example: '🎭 Preview: {operation}'", + "examples": ["🎭 Preview: {operation}", "## Staged Mode: {operation}"] }, "staged-description": { "type": "string", @@ -7999,18 +8011,18 @@ }, "run-success": { "type": "string", - "description": "Custom message template for successful workflow completion. Available placeholders: {workflow_name}, {run_url}. Default: '\u2705 Agentic [{workflow_name}]({run_url}) completed successfully.'", - "examples": ["\u2705 Agentic [{workflow_name}]({run_url}) completed successfully.", "\u2705 [{workflow_name}]({run_url}) finished."] + "description": "Custom message template for successful workflow completion. Available placeholders: {workflow_name}, {run_url}. Default: '✅ Agentic [{workflow_name}]({run_url}) completed successfully.'", + "examples": ["✅ Agentic [{workflow_name}]({run_url}) completed successfully.", "✅ [{workflow_name}]({run_url}) finished."] }, "run-failure": { "type": "string", - "description": "Custom message template for failed workflow. Available placeholders: {workflow_name}, {run_url}, {status}. Default: '\u274c Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.'", - "examples": ["\u274c Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.", "\u274c [{workflow_name}]({run_url}) {status}."] + "description": "Custom message template for failed workflow. Available placeholders: {workflow_name}, {run_url}, {status}. Default: '❌ Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.'", + "examples": ["❌ Agentic [{workflow_name}]({run_url}) {status} and wasn't able to produce a result.", "❌ [{workflow_name}]({run_url}) {status}."] }, "detection-failure": { "type": "string", - "description": "Custom message template for detection job failure. Available placeholders: {workflow_name}, {run_url}. Default: '\u26a0\ufe0f Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.'", - "examples": ["\u26a0\ufe0f Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.", "\u26a0\ufe0f Detection job failed in [{workflow_name}]({run_url})."] + "description": "Custom message template for detection job failure. Available placeholders: {workflow_name}, {run_url}. Default: '⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.'", + "examples": ["⚠️ Security scanning failed for [{workflow_name}]({run_url}). Review the logs for details.", "⚠️ Detection job failed in [{workflow_name}]({run_url})."] }, "agent-failure-issue": { "type": "string", @@ -8759,7 +8771,7 @@ }, "github-token": { "type": "string", - "description": "GitHub token expression to authenticate APM with private package repositories. Uses cascading fallback (GH_AW_PLUGINS_TOKEN \u2192 GH_AW_GITHUB_TOKEN \u2192 GITHUB_TOKEN) when not specified. Takes effect unless github-app is also configured (which takes precedence).", + "description": "GitHub token expression to authenticate APM with private package repositories. Uses cascading fallback (GH_AW_PLUGINS_TOKEN → GH_AW_GITHUB_TOKEN → GITHUB_TOKEN) when not specified. Takes effect unless github-app is also configured (which takes precedence).", "examples": ["${{ secrets.MY_TOKEN }}", "${{ secrets.GH_AW_GITHUB_TOKEN }}"] } }, @@ -9501,7 +9513,7 @@ }, "auth": { "type": "array", - "description": "Authentication bindings \u2014 maps logical roles (e.g. 'api-key') to GitHub Actions secret names", + "description": "Authentication bindings — maps logical roles (e.g. 'api-key') to GitHub Actions secret names", "items": { "type": "object", "properties": { @@ -10082,7 +10094,7 @@ "vulnerability-alerts": { "type": "string", "enum": ["read", "write", "none"], - "description": "Permission level for Dependabot vulnerability alerts (read/write/none). GitHub App-only permission: required to access Dependabot alerts via the GitHub MCP server. The GITHUB_TOKEN does not have this permission \u2014 a GitHub App must be configured." + "description": "Permission level for Dependabot vulnerability alerts (read/write/none). GitHub App-only permission: required to access Dependabot alerts via the GitHub MCP server. The GITHUB_TOKEN does not have this permission — a GitHub App must be configured." }, "all": { "type": "string", diff --git a/pkg/workflow/compiler.go b/pkg/workflow/compiler.go index bad7631137..84eef1b935 100644 --- a/pkg/workflow/compiler.go +++ b/pkg/workflow/compiler.go @@ -194,6 +194,12 @@ func (c *Compiler) validateWorkflowData(workflowData *WorkflowData, markdownPath return formatCompilerError(markdownPath, "error", err.Error(), err) } + // Validate safe-outputs allow-workflows requires GitHub App + log.Printf("Validating safe-outputs allow-workflows") + if err := validateSafeOutputsAllowWorkflows(workflowData.SafeOutputs); err != nil { + return formatCompilerError(markdownPath, "error", err.Error(), err) + } + // Validate safe-job needs: declarations against known generated job IDs log.Printf("Validating safe-job needs declarations") if err := validateSafeJobNeeds(workflowData); err != nil { diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 770c96350e..e9680b2f22 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -39,6 +39,7 @@ type CreatePullRequestsConfig struct { ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. PreserveBranchName bool `yaml:"preserve-branch-name,omitempty"` // When true, skips the random salt suffix on agent-specified branch names. Invalid characters are still replaced for security; casing is always preserved. Useful when CI enforces branch naming conventions (e.g. Jira keys in uppercase). PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "am" (default, uses git format-patch) or "bundle" (uses git bundle, preserves merge topology and per-commit metadata). + AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. } // parsePullRequestsConfig handles only create-pull-request (singular) configuration diff --git a/pkg/workflow/push_to_pull_request_branch.go b/pkg/workflow/push_to_pull_request_branch.go index a655ea4d87..85738d1a49 100644 --- a/pkg/workflow/push_to_pull_request_branch.go +++ b/pkg/workflow/push_to_pull_request_branch.go @@ -24,6 +24,7 @@ type PushToPullRequestBranchConfig struct { AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for push. Checked independently of protected-files; both checks must pass. ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "am" (default, uses git format-patch) or "bundle" (uses git bundle, preserves merge topology and per-commit metadata). + AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. } // buildCheckoutRepository generates a checkout step with optional target repository and custom token @@ -160,6 +161,13 @@ func (c *Compiler) parsePushToPullRequestBranchConfig(outputMap map[string]any) } } + // Parse allow-workflows: when true, adds workflows: write to the GitHub App token + if allowWorkflows, exists := configMap["allow-workflows"]; exists { + if allowWorkflowsBool, ok := allowWorkflows.(bool); ok { + pushToBranchConfig.AllowWorkflows = allowWorkflowsBool + } + } + // Parse common base fields with default max of 0 (no limit) c.parseBaseSafeOutputConfig(configMap, &pushToBranchConfig.BaseSafeOutputConfig, 0) } diff --git a/pkg/workflow/safe_outputs_allow_workflows_test.go b/pkg/workflow/safe_outputs_allow_workflows_test.go new file mode 100644 index 0000000000..8f7ccf10a4 --- /dev/null +++ b/pkg/workflow/safe_outputs_allow_workflows_test.go @@ -0,0 +1,336 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAllowWorkflowsPermission tests that allow-workflows: true on create-pull-request +// adds workflows: write to the computed safe-output permissions. +func TestAllowWorkflowsPermission(t *testing.T) { + tests := []struct { + name string + safeOutputs *SafeOutputsConfig + expectWorkflow bool + }{ + { + name: "create-pull-request with allow-workflows true adds workflows write", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{ + AllowWorkflows: true, + }, + }, + expectWorkflow: true, + }, + { + name: "create-pull-request without allow-workflows does not add workflows write", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expectWorkflow: false, + }, + { + name: "push-to-pull-request-branch with allow-workflows true adds workflows write", + safeOutputs: &SafeOutputsConfig{ + PushToPullRequestBranch: &PushToPullRequestBranchConfig{ + AllowWorkflows: true, + }, + }, + expectWorkflow: true, + }, + { + name: "push-to-pull-request-branch without allow-workflows does not add workflows write", + safeOutputs: &SafeOutputsConfig{ + PushToPullRequestBranch: &PushToPullRequestBranchConfig{}, + }, + expectWorkflow: false, + }, + { + name: "both handlers with allow-workflows true", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{ + AllowWorkflows: true, + }, + PushToPullRequestBranch: &PushToPullRequestBranchConfig{ + AllowWorkflows: true, + }, + }, + expectWorkflow: true, + }, + { + name: "staged create-pull-request with allow-workflows does not add workflows write", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + AllowWorkflows: true, + }, + }, + expectWorkflow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + permissions := ComputePermissionsForSafeOutputs(tt.safeOutputs) + require.NotNil(t, permissions, "Permissions should not be nil") + + level, ok := permissions.GetExplicit(PermissionWorkflows) + if tt.expectWorkflow { + assert.True(t, ok, "workflows permission should be present") + assert.Equal(t, PermissionWrite, level, "workflows should be write") + } else { + assert.False(t, ok, "workflows permission should not be present") + } + }) + } +} + +// TestAllowWorkflowsValidationRequiresGitHubApp tests that allow-workflows: true +// without a GitHub App configuration produces a validation error. +func TestAllowWorkflowsValidationRequiresGitHubApp(t *testing.T) { + tests := []struct { + name string + safeOutputs *SafeOutputsConfig + expectError bool + }{ + { + name: "nil safe outputs - no error", + safeOutputs: nil, + expectError: false, + }, + { + name: "allow-workflows without github-app - error", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{ + AllowWorkflows: true, + }, + }, + expectError: true, + }, + { + name: "allow-workflows with github-app - no error", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{ + AllowWorkflows: true, + }, + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + expectError: false, + }, + { + name: "push-to-pr-branch allow-workflows without github-app - error", + safeOutputs: &SafeOutputsConfig{ + PushToPullRequestBranch: &PushToPullRequestBranchConfig{ + AllowWorkflows: true, + }, + }, + expectError: true, + }, + { + name: "push-to-pr-branch allow-workflows with github-app - no error", + safeOutputs: &SafeOutputsConfig{ + PushToPullRequestBranch: &PushToPullRequestBranchConfig{ + AllowWorkflows: true, + }, + GitHubApp: &GitHubAppConfig{ + AppID: "${{ vars.APP_ID }}", + PrivateKey: "${{ secrets.APP_PRIVATE_KEY }}", + }, + }, + expectError: false, + }, + { + name: "no allow-workflows - no error even without github-app", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{}, + }, + expectError: false, + }, + { + name: "allow-workflows with empty github-app config - error", + safeOutputs: &SafeOutputsConfig{ + CreatePullRequests: &CreatePullRequestsConfig{ + AllowWorkflows: true, + }, + GitHubApp: &GitHubAppConfig{}, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSafeOutputsAllowWorkflows(tt.safeOutputs) + if tt.expectError { + require.Error(t, err, "Expected validation error") + assert.Contains(t, err.Error(), "allow-workflows", "Error should mention allow-workflows") + assert.Contains(t, err.Error(), "requires a GitHub App", "Error should mention GitHub App requirement") + assert.Contains(t, err.Error(), "github-app:", "Error should include configuration example") + } else { + assert.NoError(t, err, "Expected no validation error") + } + }) + } +} + +// TestAllowWorkflowsParsing tests that allow-workflows is correctly parsed from frontmatter. +func TestAllowWorkflowsParsing(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read +safe-outputs: + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + create-pull-request: + allow-workflows: true + allowed-files: + - ".github/workflows/*.lock.yml" +--- + +# Test Workflow + +Test workflow with allow-workflows on create-pull-request. +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + workflowData, err := compiler.ParseWorkflowFile(testFile) + require.NoError(t, err, "Failed to parse markdown content") + require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") + require.NotNil(t, workflowData.SafeOutputs.CreatePullRequests, "CreatePullRequests should not be nil") + + assert.True(t, workflowData.SafeOutputs.CreatePullRequests.AllowWorkflows, "AllowWorkflows should be true") + assert.Equal(t, []string{".github/workflows/*.lock.yml"}, workflowData.SafeOutputs.CreatePullRequests.AllowedFiles, "AllowedFiles should be parsed") +} + +// TestAllowWorkflowsParsingPushToPullRequestBranch tests parsing for push-to-pull-request-branch. +func TestAllowWorkflowsParsingPushToPullRequestBranch(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + markdown := `--- +on: pull_request +permissions: + contents: read +safe-outputs: + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + push-to-pull-request-branch: + allow-workflows: true + allowed-files: + - ".github/workflows/*.lock.yml" +--- + +# Test Workflow + +Test workflow with allow-workflows on push-to-pull-request-branch. +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + workflowData, err := compiler.ParseWorkflowFile(testFile) + require.NoError(t, err, "Failed to parse markdown content") + require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") + require.NotNil(t, workflowData.SafeOutputs.PushToPullRequestBranch, "PushToPullRequestBranch should not be nil") + + assert.True(t, workflowData.SafeOutputs.PushToPullRequestBranch.AllowWorkflows, "AllowWorkflows should be true") +} + +// TestAllowWorkflowsAppTokenPermission tests that when allow-workflows is true +// and a GitHub App is configured, the compiled output includes permission-workflows: write. +func TestAllowWorkflowsAppTokenPermission(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read +safe-outputs: + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + create-pull-request: + allow-workflows: true + allowed-files: + - ".github/workflows/*.lock.yml" +--- + +# Test Workflow + +Test workflow checking permission-workflows: write in GitHub App token. +` + + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + workflowData, err := compiler.ParseWorkflowFile(testFile) + require.NoError(t, err, "Failed to parse workflow") + require.NotNil(t, workflowData.SafeOutputs, "SafeOutputs should not be nil") + + job, _, err := compiler.buildConsolidatedSafeOutputsJob(workflowData, "main", testFile) + require.NoError(t, err, "Failed to build safe_outputs job") + require.NotNil(t, job, "Job should not be nil") + + stepsStr := strings.Join(job.Steps, "") + assert.Contains(t, stepsStr, "permission-workflows: write", "GitHub App token should include workflows write permission") +} + +// TestAllowWorkflowsCompileErrorWithoutGitHubApp tests that compiling a workflow +// with allow-workflows: true but no GitHub App produces a compile error. +func TestAllowWorkflowsCompileErrorWithoutGitHubApp(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read +safe-outputs: + create-pull-request: + allow-workflows: true + allowed-files: + - ".github/workflows/*.lock.yml" +--- + +# Test Workflow + +Test workflow with allow-workflows but no GitHub App. +` + + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(workflowsDir, 0755)) + mdPath := filepath.Join(workflowsDir, "test.md") + err := os.WriteFile(mdPath, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmpDir)) + defer func() { _ = os.Chdir(origDir) }() + + err = compiler.CompileWorkflow(mdPath) + require.Error(t, err, "Compilation should fail without GitHub App") + assert.Contains(t, err.Error(), "allow-workflows", "Error should mention allow-workflows") +} diff --git a/pkg/workflow/safe_outputs_permissions.go b/pkg/workflow/safe_outputs_permissions.go index 7df2a668b2..14c0c7b5b6 100644 --- a/pkg/workflow/safe_outputs_permissions.go +++ b/pkg/workflow/safe_outputs_permissions.go @@ -130,10 +130,20 @@ func ComputePermissionsForSafeOutputs(safeOutputs *SafeOutputsConfig) *Permissio safeOutputsPermissionsLog.Print("Adding permissions for create-pull-request") permissions.Merge(NewPermissionsContentsWritePRWrite()) } + // Add workflows: write when allow-workflows is true (GitHub App-only permission) + if safeOutputs.CreatePullRequests.AllowWorkflows { + safeOutputsPermissionsLog.Print("Adding workflows: write for create-pull-request (allow-workflows: true)") + permissions.Set(PermissionWorkflows, PermissionWrite) + } } if safeOutputs.PushToPullRequestBranch != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.PushToPullRequestBranch.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for push-to-pull-request-branch") permissions.Merge(NewPermissionsContentsWritePRWrite()) + // Add workflows: write when allow-workflows is true (GitHub App-only permission) + if safeOutputs.PushToPullRequestBranch.AllowWorkflows { + safeOutputsPermissionsLog.Print("Adding workflows: write for push-to-pull-request-branch (allow-workflows: true)") + permissions.Set(PermissionWorkflows, PermissionWrite) + } } if safeOutputs.UpdatePullRequests != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.UpdatePullRequests.Staged) { safeOutputsPermissionsLog.Print("Adding permissions for update-pull-request") diff --git a/pkg/workflow/safe_outputs_validation.go b/pkg/workflow/safe_outputs_validation.go index 75b0e6fd61..8f119a6d5c 100644 --- a/pkg/workflow/safe_outputs_validation.go +++ b/pkg/workflow/safe_outputs_validation.go @@ -307,3 +307,52 @@ func validateSafeOutputsMax(config *SafeOutputsConfig) error { safeOutputsMaxValidationLog.Print("Safe-outputs max fields validation passed") return nil } + +var safeOutputsAllowWorkflowsValidationLog = newValidationLogger("safe_outputs_allow_workflows") + +// validateSafeOutputsAllowWorkflows validates that allow-workflows: true requires +// a GitHub App to be configured in safe-outputs.github-app. The workflows permission +// is a GitHub App-only permission and cannot be granted via GITHUB_TOKEN. +func validateSafeOutputsAllowWorkflows(safeOutputs *SafeOutputsConfig) error { + if safeOutputs == nil { + return nil + } + + hasAllowWorkflows := false + var handlers []string + + if safeOutputs.CreatePullRequests != nil && safeOutputs.CreatePullRequests.AllowWorkflows { + hasAllowWorkflows = true + handlers = append(handlers, "create-pull-request") + } + if safeOutputs.PushToPullRequestBranch != nil && safeOutputs.PushToPullRequestBranch.AllowWorkflows { + hasAllowWorkflows = true + handlers = append(handlers, "push-to-pull-request-branch") + } + + if !hasAllowWorkflows { + return nil + } + + safeOutputsAllowWorkflowsValidationLog.Printf("allow-workflows: true found on: %s", strings.Join(handlers, ", ")) + + // Check if GitHub App is configured with required fields + if safeOutputs.GitHubApp == nil || safeOutputs.GitHubApp.AppID == "" || safeOutputs.GitHubApp.PrivateKey == "" { + safeOutputsAllowWorkflowsValidationLog.Print("allow-workflows requires github-app but none configured") + return fmt.Errorf( + "safe-outputs.%s.allow-workflows: requires a GitHub App to be configured.\n"+ + "The workflows permission is a GitHub App-only permission and cannot be granted via GITHUB_TOKEN.\n\n"+ + "Add a GitHub App configuration to safe-outputs:\n\n"+ + "safe-outputs:\n"+ + " github-app:\n"+ + " app-id: ${{ vars.APP_ID }}\n"+ + " private-key: ${{ secrets.APP_PRIVATE_KEY }}\n"+ + " %s:\n"+ + " allow-workflows: true", + handlers[0], handlers[0], + ) + } + + safeOutputsAllowWorkflowsValidationLog.Print("allow-workflows validation passed") + return nil +}