|
| 1 | +# Production Gotchas |
| 2 | + |
| 3 | +Hard-won learnings from debugging gh-aw workflows in production. Each item was verified through real production failures and hours of debugging. Organized by category for quick reference. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## Expression Interpolation |
| 8 | + |
| 9 | +### Code Block Interpolation Behavior |
| 10 | + |
| 11 | +`${{ }}` expressions in the workflow markdown body **are** interpolated when they appear in plain text, but are **NOT** interpolated inside fenced code blocks (` ```bash `, ` ```yaml `, etc.). |
| 12 | + |
| 13 | +**Symptom:** The agent receives the literal string `${{ github.event.issue.number }}` inside a code block. Bash cannot parse `${{`, and the command fails silently or produces unexpected results. |
| 14 | + |
| 15 | +**Fix:** Declare environment variables in frontmatter `env:` (or in the prose as env var instructions), then reference them as `$VARIABLE_NAME` inside code blocks. |
| 16 | + |
| 17 | +**Example — WRONG:** |
| 18 | + |
| 19 | +````markdown |
| 20 | +```bash |
| 21 | +gh issue view ${{ github.event.issue.number }} |
| 22 | +``` |
| 23 | +```` |
| 24 | + |
| 25 | +The agent receives the literal text `${{ github.event.issue.number }}` and bash fails. |
| 26 | + |
| 27 | +**Example — CORRECT:** |
| 28 | + |
| 29 | +Declare in prose (before code blocks): |
| 30 | +```markdown |
| 31 | +The issue number is available as `$ISSUE_NUMBER` (from `${{ github.event.issue.number }}`). |
| 32 | +``` |
| 33 | + |
| 34 | +Or instruct the agent to read event context: |
| 35 | +```markdown |
| 36 | +Read the issue number from the event payload or the `event.json` file. |
| 37 | +``` |
| 38 | + |
| 39 | +Then in code blocks, use shell variables: |
| 40 | +````markdown |
| 41 | +```bash |
| 42 | +gh issue view "$ISSUE_NUMBER" |
| 43 | +``` |
| 44 | +```` |
| 45 | + |
| 46 | +**Key principle:** Plain text expressions are interpolated at compile time. Fenced code block contents are passed to the agent verbatim. |
| 47 | + |
| 48 | +--- |
| 49 | + |
| 50 | +## GitHub App & Token Conflicts |
| 51 | + |
| 52 | +### tools.github.app Permission Inheritance |
| 53 | + |
| 54 | +When using `tools.github.app` in frontmatter, the GitHub MCP server requests **ALL** workflow-level permissions for the App token — not just the ones the MCP tools need. |
| 55 | + |
| 56 | +**Symptom:** HTTP 422 error: `"permissions requested are not granted to this app"`. The App token creation fails because the App installation doesn't have a permission that's declared in the workflow's `permissions:` block but isn't relevant to the MCP tools. |
| 57 | + |
| 58 | +**Example:** Workflow has `packages: read` (for GHCR docker login in `steps:`), and `tools.github.app` is configured. The App token requests `packages:read`, but the GitHub App doesn't have the Packages permission installed. Result: 422 error on App token creation. |
| 59 | + |
| 60 | +**Fix:** NEVER use `tools.github.app` unless the GitHub App has **ALL** permissions declared in the workflow's `permissions:` block. For read-only GitHub MCP access, omit `app:` entirely — `GITHUB_TOKEN` works fine for read operations. |
| 61 | + |
| 62 | +**Decision tree:** |
| 63 | +1. Does the agent need to **write** via MCP tools (not safe-outputs)? → Use `tools.github.app` |
| 64 | +2. Does the agent only need to **read** via MCP tools? → Omit `app:`, use default `GITHUB_TOKEN` |
| 65 | +3. Does the workflow have permissions the App doesn't have? → Remove `app:` or remove those permissions |
| 66 | + |
| 67 | +### tools.github MCP Conflicts with gh CLI |
| 68 | + |
| 69 | +When `tools.github` is configured in frontmatter, the GitHub MCP server takes ownership of the `GITHUB_TOKEN`. This prevents the `gh` CLI from authenticating in bash tool calls. |
| 70 | + |
| 71 | +**Symptom:** Agent logs show `"Detected conflict with gh CLI authentication"` or `"missing tool"`. Bash commands using `gh` fail with authentication errors. |
| 72 | + |
| 73 | +**Fix:** If the workflow **only** uses `gh` CLI via bash (not GitHub MCP tools), remove `tools.github` entirely from the frontmatter. The `gh` CLI authenticates directly with `GITHUB_TOKEN` and doesn't need the MCP server. |
| 74 | + |
| 75 | +**Coexistence rule:** You can use `tools.github` AND `gh` CLI in the same workflow, but be aware that the MCP server may interfere with `gh` CLI auth. If you hit auth conflicts, choose one approach: |
| 76 | +- MCP tools only (via `tools.github`) for structured API access |
| 77 | +- `gh` CLI only (via `tools.bash: ["gh:*"]`) for shell-based access |
| 78 | + |
| 79 | +--- |
| 80 | + |
| 81 | +## Safe-Output Gotchas |
| 82 | + |
| 83 | +### add-comment Defaults to discussions:write |
| 84 | + |
| 85 | +The `add-comment` safe-output silently requests `discussions:write` permission by default, even if the workflow never touches discussions. |
| 86 | + |
| 87 | +**Symptom:** HTTP 422 error when the safe-output execution job tries to create the App token — the App doesn't have Discussions permission. |
| 88 | + |
| 89 | +**Fix:** Explicitly set `discussions: false` under `add-comment:` in safe-outputs: |
| 90 | + |
| 91 | +```yaml |
| 92 | +safe-outputs: |
| 93 | + add-comment: |
| 94 | + discussions: false |
| 95 | + max: 1 |
| 96 | +``` |
| 97 | +
|
| 98 | +### No merge-pull-request Safe Output |
| 99 | +
|
| 100 | +gh-aw has **NO** safe-output for merging pull requests. The complete list of valid safe-outputs is: |
| 101 | +
|
| 102 | +- `add-comment` |
| 103 | +- `add-labels` |
| 104 | +- `add-reviewer` |
| 105 | +- `assign-milestone` |
| 106 | +- `assign-to-agent` |
| 107 | +- `assign-to-user` |
| 108 | +- `autofix-code-scanning-alert` |
| 109 | +- `close-issue` |
| 110 | +- `close-pull-request` |
| 111 | +- `create-agent-session` |
| 112 | +- `create-code-scanning-alert` |
| 113 | +- `create-discussion` |
| 114 | +- `create-issue` |
| 115 | +- `create-project` |
| 116 | +- `create-project-status-update` |
| 117 | +- `create-pull-request` |
| 118 | +- `create-pull-request-review-comment` |
| 119 | +- `dispatch-workflow` |
| 120 | +- `hide-comment` |
| 121 | +- `link-sub-issue` |
| 122 | +- `push-to-pull-request-branch` |
| 123 | +- `remove-labels` |
| 124 | +- `reply-to-pull-request-review-comment` |
| 125 | +- `resolve-pull-request-review-thread` |
| 126 | +- `submit-pull-request-review` |
| 127 | +- `unassign-from-user` |
| 128 | +- `update-discussion` |
| 129 | +- `update-issue` |
| 130 | +- `update-project` |
| 131 | +- `update-pull-request` |
| 132 | +- `update-release` |
| 133 | +- `upload-asset` |
| 134 | + |
| 135 | +**If you need to merge a PR**, use `post-steps` with a fresh App token: |
| 136 | + |
| 137 | +```yaml |
| 138 | +post-steps: |
| 139 | + - name: Generate merge token |
| 140 | + id: merge-token |
| 141 | + uses: actions/create-github-app-token@v2 |
| 142 | + with: |
| 143 | + app-id: ${{ vars.APP_ID }} |
| 144 | + private-key: ${{ secrets.APP_KEY }} |
| 145 | + owner: org-name |
| 146 | + repositories: repo-name |
| 147 | + - name: Auto-merge PR |
| 148 | + env: |
| 149 | + GH_TOKEN: ${{ steps.merge-token.outputs.token }} |
| 150 | + PR_NUMBER: ${{ github.event.pull_request.number }} |
| 151 | + run: | |
| 152 | + gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --squash --auto |
| 153 | +``` |
| 154 | + |
| 155 | +--- |
| 156 | + |
| 157 | +## Permissions & Compilation |
| 158 | + |
| 159 | +### Write Permissions Not Allowed in Frontmatter |
| 160 | + |
| 161 | +The gh-aw compiler rejects write permissions in the `permissions:` block. For example, `permissions: issues: write` fails compilation. |
| 162 | + |
| 163 | +**Reason:** ALL write operations must go through safe-outputs, which use the App token internally. The workflow `permissions:` block is strictly for `GITHUB_TOKEN` scoping and is read-only. |
| 164 | + |
| 165 | +**Fix:** Declare only `read` permissions in frontmatter. Use safe-outputs for all write operations. |
| 166 | + |
| 167 | +**WRONG:** |
| 168 | +```yaml |
| 169 | +permissions: |
| 170 | + issues: write |
| 171 | + pull-requests: write |
| 172 | +``` |
| 173 | + |
| 174 | +**CORRECT:** |
| 175 | +```yaml |
| 176 | +permissions: |
| 177 | + issues: read |
| 178 | + pull-requests: read |
| 179 | +
|
| 180 | +safe-outputs: |
| 181 | + add-labels: |
| 182 | + allowed: [bug, feature] |
| 183 | + add-comment: |
| 184 | + discussions: false |
| 185 | +``` |
| 186 | + |
| 187 | +--- |
| 188 | + |
| 189 | +## Workflow Files & Push Restrictions |
| 190 | + |
| 191 | +### .lock.yml Files Exempt from Workflow Push Restriction |
| 192 | + |
| 193 | +Standard `.yml`/`.yaml` files in `.github/workflows/` block GitHub App token branch pushes. The error is: |
| 194 | + |
| 195 | +> "refusing to allow a GitHub App to create or update workflow without `workflows` permission" |
| 196 | + |
| 197 | +This happens because GitHub treats any `.yml`/`.yaml` file in `.github/workflows/` as a workflow definition and requires the `workflows` permission scope for modifications — a scope that GitHub Apps cannot have. |
| 198 | + |
| 199 | +**Key insight:** gh-aw compiled `.lock.yml` files are **EXEMPT** from this restriction. The `.lock.yml` extension is specifically excluded from GitHub's workflow file detection. |
| 200 | + |
| 201 | +**Rule:** NEVER add standard GitHub Actions `.yml`/`.yaml` workflow files alongside gh-aw workflows if using App tokens for safe-outputs (e.g., `create-pull-request`). The App token push will fail because the `.yml` file triggers the workflow push restriction. |
| 202 | + |
| 203 | +**If you need standard GH Actions functionality:** |
| 204 | +- Move the logic into a gh-aw workflow as a `post-steps` block |
| 205 | +- Or use a completely separate repository for standard workflows |
| 206 | +- Or ensure the PR branch never contains `.yml`/`.yaml` files in `.github/workflows/` |
| 207 | + |
| 208 | +--- |
| 209 | + |
| 210 | +## Frontmatter Features |
| 211 | + |
| 212 | +### `if` Guard (Conditional Execution) |
| 213 | + |
| 214 | +The frontmatter-level `if:` field prevents workflow runs from starting entirely. This is evaluated **before** any jobs run, saving compute compared to job-level conditionals. |
| 215 | + |
| 216 | +```yaml |
| 217 | +if: contains(github.event.issue.labels.*.name, 'status:assessed') && contains(github.event.issue.labels.*.name, 'source:jira') |
| 218 | +``` |
| 219 | + |
| 220 | +**Use for:** Scoping event-triggered workflows to specific label combinations, issue states, or other event conditions that should prevent the agent from running at all. |
| 221 | + |
| 222 | +### post-steps Feature |
| 223 | + |
| 224 | +`post-steps:` is a valid frontmatter field for defining custom workflow steps that run **after** AI execution completes. |
| 225 | + |
| 226 | +**Characteristics:** |
| 227 | +- Runs in the agent job after the Codex/Claude/Copilot agent finishes |
| 228 | +- Has access to full job context: `${{ github.event.* }}`, `${{ secrets.* }}`, `${{ vars.* }}` |
| 229 | +- Can reference step outputs from earlier steps via `${{ steps.*.outputs.* }}` |
| 230 | +- Compiles into GitHub Actions job steps that execute sequentially after the agent step |
| 231 | + |
| 232 | +**Use cases:** |
| 233 | +- Merging PRs (no safe-output exists for this — see above) |
| 234 | +- Closing issues with custom logic |
| 235 | +- Cleanup operations |
| 236 | +- Anything requiring write access via a fresh App token |
| 237 | + |
| 238 | +```yaml |
| 239 | +post-steps: |
| 240 | + - name: Generate token |
| 241 | + id: app-token |
| 242 | + uses: actions/create-github-app-token@v2 |
| 243 | + with: |
| 244 | + app-id: ${{ vars.APP_ID }} |
| 245 | + private-key: ${{ secrets.APP_KEY }} |
| 246 | + - name: Cleanup |
| 247 | + env: |
| 248 | + GH_TOKEN: ${{ steps.app-token.outputs.token }} |
| 249 | + run: | |
| 250 | + echo "Post-agent cleanup" |
| 251 | +``` |
| 252 | + |
| 253 | +--- |
| 254 | + |
| 255 | +## MCP Server Constraints |
| 256 | + |
| 257 | +### JSON Escaping in entrypointArgs |
| 258 | + |
| 259 | +`gh aw compile` does **NOT** escape double quotes (`"`) in `entrypointArgs` values for MCP servers. If a command string contains double quotes, the compiled JSON will be malformed. |
| 260 | + |
| 261 | +**Symptom:** Compilation succeeds but the MCP server fails to start at runtime because the Docker CMD array has broken JSON. |
| 262 | + |
| 263 | +**Fix:** NEVER use double quotes in command strings passed to MCP server configs. Use alternative approaches: |
| 264 | +- Use `grep` instead of `jq` if the jq expression would contain quotes |
| 265 | +- Use single-quoted strings where the shell allows it |
| 266 | +- Use `sed` or `awk` with patterns that avoid quotes |
| 267 | + |
| 268 | +**WRONG:** |
| 269 | +```yaml |
| 270 | +mcp-servers: |
| 271 | + my-server: |
| 272 | + container: ghcr.io/org/image:latest |
| 273 | + entrypointArgs: |
| 274 | + - "jq '.data[\"key\"]' input.json" |
| 275 | +``` |
| 276 | + |
| 277 | +**CORRECT:** |
| 278 | +```yaml |
| 279 | +mcp-servers: |
| 280 | + my-server: |
| 281 | + container: ghcr.io/org/image:latest |
| 282 | + entrypointArgs: |
| 283 | + - "grep key input.json" |
| 284 | +``` |
| 285 | + |
| 286 | +### MCP Server Stdout Constraint |
| 287 | + |
| 288 | +The MCP gateway reads server stdout as JSON-RPC. **ANY** stdout output before the MCP handshake breaks server initialization. |
| 289 | + |
| 290 | +**Symptom:** MCP server silently fails to initialize. The agent gets no tools from that server. No obvious error in the workflow logs — check `agent-artifacts/mcp-logs/{server}.log`. |
| 291 | + |
| 292 | +**Common causes:** |
| 293 | +- `apk add` or `apt-get install` printing progress to stdout |
| 294 | +- `pip install` or `npm install` printing to stdout |
| 295 | +- `curl` printing download progress |
| 296 | +- Python `print()` statements in server startup code |
| 297 | +- Shell scripts echoing status messages |
| 298 | + |
| 299 | +**Fix:** Redirect ALL installation/setup output to `/dev/null`: |
| 300 | + |
| 301 | +```yaml |
| 302 | +entrypointArgs: |
| 303 | + - "sh" |
| 304 | + - "-c" |
| 305 | + - "apk add --no-cache curl >/dev/null 2>&1 && exec my-server" |
| 306 | +``` |
| 307 | + |
| 308 | +### `gh aw mcp inspect/list` Limitation |
| 309 | + |
| 310 | +The `gh aw mcp inspect` and `gh aw mcp list` commands do **NOT** follow `imports:` directives. They only see MCP servers declared in the direct frontmatter of the workflow file being inspected. |
| 311 | + |
| 312 | +**Fix:** To verify imported MCP servers, check the compiled `.lock.yml` file instead, which contains the fully resolved configuration. |
| 313 | + |
| 314 | +--- |
| 315 | + |
| 316 | +## Trigger Behavior |
| 317 | + |
| 318 | +### pull_request Trigger and Workflow File Resolution |
| 319 | + |
| 320 | +The `pull_request` trigger uses the workflow definition from the **merge commit** (base branch + PR branch merged together). This means: |
| 321 | + |
| 322 | +1. If you push a new `.lock.yml` to `main` and immediately reopen/re-trigger a PR, the merge ref may still use the **old** version of `main` |
| 323 | +2. GitHub needs a moment to update the merge ref after pushes to the base branch |
| 324 | + |
| 325 | +**Symptom:** A PR re-run uses an outdated workflow definition even though you just pushed the correct one to `main`. |
| 326 | + |
| 327 | +**Fix:** Wait a few seconds between pushing to `main` and triggering `pull_request` events. If testing workflow changes, close and reopen the PR (or push a new commit to the PR branch) to force a merge ref rebuild. |
| 328 | + |
| 329 | +**Related:** For `push` triggers, the workflow is always from the pushed commit. For `workflow_dispatch`, it's from the branch selected in the UI. |
0 commit comments