feat: support github-app in max-daily-ai-credits for dedicated guardrail token#43508
Conversation
Add support for specifying a dedicated GitHub App in the
max-daily-ai-credits frontmatter field via an object form:
max-daily-ai-credits:
value: 123
github-app:
client-id: ${{ vars.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
The github-app is used to mint a dedicated token for the daily AIC
guardrail API calls, avoiding depletion of credits from other apps.
Changes:
- daily_aic_workflow.go: parse object form, extract github-app config
- workflow_data.go: add MaxDailyAICreditsGitHubApp field
- workflow_builder.go: populate MaxDailyAICreditsGitHubApp
- compiler_activation_job_builder.go: mint token and use in guardrail
- main_workflow_schema.json: add object form to schema
- daily_aic_workflow_guardrail_test.go: add tests for new functionality
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
…value key Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
|
✅ Design Decision Gate 🏗️ completed the design decision gate check. |
|
✅ Test Quality Sentinel completed test quality analysis. |
|
|
|
🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅ |
There was a problem hiding this comment.
Pull request overview
Adds support for configuring a dedicated GitHub App token for the max-daily-ai-credits guardrail, so its GitHub API calls don’t consume credits from the activation app token (or default GITHUB_TOKEN). This extends frontmatter to accept an object form that includes both the credit limit and an optional github-app token-mint configuration.
Changes:
- Add object-form parsing for
max-daily-ai-creditsand extract an optionalgithub-appconfig. - Extend
WorkflowData/ builder wiring to carry the dedicated app config through compilation. - Emit an activation-job token-mint step for the daily AIC guardrail and use its token for guardrail API calls; update schema + add tests.
Show a summary per file
| File | Description |
|---|---|
| pkg/workflow/workflow_data.go | Adds MaxDailyAICreditsGitHubApp to carry the optional dedicated app config. |
| pkg/workflow/workflow_builder.go | Populates MaxDailyAICreditsGitHubApp from frontmatter during workflow data construction. |
| pkg/workflow/daily_aic_workflow.go | Normalizes object-form max-daily-ai-credits by extracting value; adds helper to extract github-app. |
| pkg/workflow/daily_aic_workflow_guardrail_test.go | Adds coverage for object-form parsing/extraction and compiled workflow output expectations. |
| pkg/workflow/compiler_activation_job_builder.go | Adds daily-AIC app token mint step + token resolution to use dedicated token for guardrail steps. |
| pkg/parser/schemas/main_workflow_schema.json | Extends schema to allow max-daily-ai-credits object form with required value and optional github-app. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 6/6 changed files
- Comments generated: 3
- Review effort level: Low
| func (c *Compiler) buildDailyAICAppTokenMintStep(app *GitHubAppConfig, data *WorkflowData) []string { | ||
| var steps []string | ||
| steps = append(steps, " - name: Generate GitHub App token for daily AIC guardrail\n") |
| if data.MaxDailyAICreditsGitHubApp != nil { | ||
| if data.MaxDailyAICreditsGitHubApp.shouldIgnoreMissingKey() { | ||
| return combineTokenExpressions( | ||
| fmt.Sprintf("${{ steps.%s.outputs.token }}", dailyAICAppTokenStepID), | ||
| "${{ secrets.GITHUB_TOKEN }}", | ||
| ) | ||
| } |
| "value": { | ||
| "$ref": "#/$defs/positive_ai_credits_limit", | ||
| "description": "The maximum AI Credits budget (positive integer, K/M suffix, or expression)." | ||
| }, |
Design Decision Gate — ADR RequiredThis PR makes significant changes to core business logic (317 new lines in Draft ADR committed:
What to do next
Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision. Why ADRs MatterADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you. Michael Nygard ADR Format ReferenceAn ADR must contain these four sections to be considered complete:
All ADRs are stored in
|
🧪 Test Quality Sentinel Report✅ Test Quality Score: 88/100 — Excellent
📊 Metrics (17 tests)
Build tag: ✅
Inflation note: The test file adds 115 lines vs 57 in Highlights
Verdict
Warning Firewall blocked 1 domainThe following domain was blocked by the firewall during workflow execution:
network:
allowed:
- defaults
- "awmgmcpg"See Network Configuration for more information.
|
There was a problem hiding this comment.
Review: feat: support github-app in max-daily-ai-credits for dedicated guardrail token
The overall approach is clean and well-structured — the scalar fallback is preserved, the JSON schema is updated, and the tests cover the main happy paths. Two correctness issues need fixing before merge; one code-clarity issue is a follow-up.
Blocking
-
repositoriesfield is hardcoded in the mint step (compiler_activation_job_builder.go:412)
buildDailyAICAppTokenMintStepalways emitsrepositories: ${{ github.event.repository.name }}, ignoringapp.Repositories. The analogousbuildPreActivationAppTokenMintStephandles single-repo, multi-repo, and org-wide (*) cases correctly. A user who configuresrepositories:on the AIC github-app will get a token with the wrong scope silently. -
app.Permissionsis never forwarded (compiler_activation_job_builder.go:415)
Onlypermission-actions: readis emitted, ignoring anypermissions:keys the user may have set on theGitHubAppConfig. Either enforce thatpermissions:is not allowed on the AIC app config (with a validation error), or forward them consistently with the rest of the infrastructure.
Non-blocking
- Double
extractMaxDailyAICObjectValuecall (daily_aic_workflow.go:115)
InresolveMaxDailyAICFromRaw,effectiveis already unwrapped, then passed toisMaxDailyAICDisabled(effective)which extracts again. Benign today but noisy and fragile.
🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 101.9 AIC · ⌖ 6.38 AIC · ⊞ 4.9K
| owner = "${{ github.repository_owner }}" | ||
| } | ||
| steps = append(steps, fmt.Sprintf(" owner: %s\n", owner)) | ||
| steps = append(steps, " repositories: ${{ github.event.repository.name }}\n") |
There was a problem hiding this comment.
The repositories field is hardcoded to ${{ github.event.repository.name }} and ignores the user-configured app.Repositories values. Compare to buildPreActivationAppTokenMintStep (compiler_pre_activation_job.go:665–675) which correctly handles single-repo, multi-repo, and org-wide (*) cases.
If a user supplies a non-default repository scope in their github-app: config, the minted token will silently ignore it. The same handling from the pre-activation step should be applied here:
if len(app.Repositories) == 1 && app.Repositories[0] == "*" {
// org-wide: omit repositories field
} else if len(app.Repositories) == 1 {
steps = append(steps, fmt.Sprintf(" repositories: %s\n", app.Repositories[0]))
} else if len(app.Repositories) > 1 {
steps = append(steps, " repositories: |-\n")
for _, repo := range app.Repositories {
steps = append(steps, fmt.Sprintf(" %s\n", repo))
}
} else {
steps = append(steps, " repositories: ${{ github.event.repository.name }}\n")
}@copilot please address this.
| steps = append(steps, " repositories: ${{ github.event.repository.name }}\n") | ||
| steps = append(steps, " github-api-url: ${{ github.api_url }}\n") | ||
| // The guardrail script reads workflow run data, so actions: read is required. | ||
| steps = append(steps, " permission-actions: read\n") |
There was a problem hiding this comment.
The mint step hardcodes only permission-actions: read and ignores the app.Permissions map that users may configure on the GitHubAppConfig struct. The other mint steps (e.g. the GitHub MCP step) forward extra permissions from the config. If this is intentional (guardrail only ever needs actions:read), that constraint should be enforced at parse time by rejecting permissions: on the AIC app config. Otherwise, the extra permissions should be merged here to be consistent with the rest of the app-token infrastructure.
@copilot please address this.
| func resolveMaxDailyAICFromRaw(raw any) (*string, bool) { | ||
| if isMaxDailyAICDisabled(raw) { | ||
| effective := extractMaxDailyAICObjectValue(raw) | ||
| if isMaxDailyAICDisabled(effective) { |
There was a problem hiding this comment.
Double extraction: effective was already obtained by extractMaxDailyAICObjectValue(raw) on line 114, but it is then passed to isMaxDailyAICDisabled(effective) which calls extractMaxDailyAICObjectValue(effective) again internally. This is benign today (the second call is a no-op because effective is already a scalar), but it creates a confusing read and would silently misbehave if the logic ever changes.
Either have isMaxDailyAICDisabled accept a pre-unwrapped value and skip the internal extraction, or inline the check here and pass the already-unwrapped effective to a simpler helper.
@copilot please address this.
Review Summary — PR #43508Applied 🔍 Issues found (5)
✅ What's working well
@copilot please address the review comments above.
|
There was a problem hiding this comment.
Skills-Based Review 🧠
Applied /tdd and /codebase-design — requesting changes on a few correctness and coverage gaps before merge.
📋 Key Themes & Highlights
Key Themes
repositoriesfield silently ignored (correctness):buildDailyAICAppTokenMintStephardcodes${{ github.event.repository.name }}and never consultsapp.Repositories, unlike every other mint step in the codebase.ignore-if-missingpath is unreachable (correctness +/tdd):extractMaxDailyAICGitHubAppreturnsnilwheneverAppIDorPrivateKeyis empty, butresolveDailyAICTokenhas a dedicatedignore-if-missingfallback branch that can therefore never trigger.- Test coverage gaps (
/tdd): No test for object-form-1(disabled), no assertion on the mint stepif:condition, and no test forignore-if-missingcombined with the object form. - Unused
dataparameter (/codebase-design):buildDailyAICAppTokenMintStep(app, data)never readsdata, adding noise to the signature. - Hardcoded
permission-actions: read(/codebase-design): The baseline permission is necessary but bypasses the normalapp.Permissionsmerge path, silently discarding any user-supplied permission overrides.
Positive Highlights
- ✅ Clean backward-compatible design: scalar form continues to work unchanged
- ✅
extractMaxDailyAICObjectValuenicely normalises both forms with a single helper used consistently across all three affected callsites - ✅ Good use of the
maxDailyAICreditsConfiguredIfExprguard to ensure the mint step skips when the guardrail is inactive - ✅ Solid end-to-end test (
TestMaxDailyAICWithGitHubAppCompiledWorkflow) that compiles a real workflow and inspects the lock file output - ✅ Step ID extracted as a named constant (
dailyAICAppTokenStepID), making future refactoring safe
🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 137.1 AIC · ⌖ 8.81 AIC · ⊞ 6.6K
Comment /matt to run again
| owner = "${{ github.repository_owner }}" | ||
| } | ||
| steps = append(steps, fmt.Sprintf(" owner: %s\n", owner)) | ||
| steps = append(steps, " repositories: ${{ github.event.repository.name }}\n") |
There was a problem hiding this comment.
[/tdd] The repositories field configured on the github-app object is silently ignored — buildPreActivationAppTokenMintStep honours app.Repositories with a full multi-value path, but this step always emits the hardcoded current-repo value, creating a surprising behavioural gap. A test asserting that a configured repositories list appears in the compiled output would surface this.
💡 Suggested fix
Replace the single hardcoded line with the same repository-resolution logic used in buildPreActivationAppTokenMintStep:
if len(app.Repositories) == 1 && app.Repositories[0] == "*" {
// org-wide: omit repositories field
} else if len(app.Repositories) == 1 {
steps = append(steps, fmt.Sprintf(" repositories: %s\n", app.Repositories[0]))
} else if len(app.Repositories) > 1 {
steps = append(steps, " repositories: |-\n")
for _, repo := range app.Repositories {
steps = append(steps, fmt.Sprintf(" %s\n", repo))
}
} else {
steps = append(steps, " repositories: ${{ github.event.repository.name }}\n")
}@copilot please address this.
| // | ||
| // The step is gated on maxDailyAICreditsConfiguredIfExpr so it is skipped when | ||
| // the guardrail is not active at runtime. | ||
| func (c *Compiler) buildDailyAICAppTokenMintStep(app *GitHubAppConfig, data *WorkflowData) []string { |
There was a problem hiding this comment.
[/codebase-design] The data *WorkflowData parameter is accepted but never read inside this function — only app is used. This creates dead weight in the signature and makes the function's dependencies harder to understand at a glance.
💡 Suggested change
Drop the unused parameter:
func (c *Compiler) buildDailyAICAppTokenMintStep(app *GitHubAppConfig) []string {Update the single call-site accordingly:
steps = append(steps, c.buildDailyAICAppTokenMintStep(data.MaxDailyAICreditsGitHubApp)...)Keeping signatures minimal makes the function easier to test in isolation (no WorkflowData scaffolding needed).
@copilot please address this.
| return nil | ||
| } | ||
| app := parseAppConfig(appMap) | ||
| if app.AppID == "" || app.PrivateKey == "" { |
There was a problem hiding this comment.
[/tdd] An app with ignore-if-missing: true but empty AppID/PrivateKey is silently discarded here. But resolveDailyAICToken specifically has an ignore-if-missing branch (combineTokenExpressions) that can never be reached from this extractor, since it always returns nil for such configs. There is no test covering this code path.
💡 Suggested test and design note
t.Run("object form with ignore-if-missing is preserved", func(t *testing.T) {
frontmatter := map[string]any{
"max-daily-ai-credits": map[string]any{
"value": 5000,
"github-app": map[string]any{
"client-id": "${{ vars.APP_ID }}",
"private-key": "${{ secrets.APP_KEY }}",
"ignore-if-missing": true,
},
},
}
app := extractMaxDailyAICGitHubApp(frontmatter)
if app == nil {
t.Fatal("expected non-nil app when ignore-if-missing is set")
}
if !app.IgnoreIfMissing {
t.Fatal("expected IgnoreIfMissing to be true")
}
})Consider whether the guard on line 63 (app.AppID == "" || app.PrivateKey == "") should be relaxed to allow ignore-if-missing: true with empty fields, mirroring how other app configs behave.
@copilot please address this.
| steps = append(steps, " repositories: ${{ github.event.repository.name }}\n") | ||
| steps = append(steps, " github-api-url: ${{ github.api_url }}\n") | ||
| // The guardrail script reads workflow run data, so actions: read is required. | ||
| steps = append(steps, " permission-actions: read\n") |
There was a problem hiding this comment.
[/codebase-design] The permission-actions: read permission is hardcoded here rather than derived from app.Permissions, which is the standard pattern for all other app token mint steps. This means users cannot extend or override permissions for the AIC guardrail app token (e.g., to add workflows: read if their guardrail script needs it in future).
💡 Suggested improvement
Derive permissions from app.Permissions merged with the guardrail-required baseline, consistent with how buildPreActivationAppTokenMintStep handles this via the shared permissions mechanism. At minimum, document why AIC-specific permissions are fixed at this value so future maintainers don't inadvertently remove the constraint.
@copilot please address this.
| }) | ||
| } | ||
|
|
||
| func TestMaxDailyAICWithGitHubAppCompiledWorkflow(t *testing.T) { |
There was a problem hiding this comment.
[/tdd] TestMaxDailyAICWithGitHubAppCompiledWorkflow verifies that the mint step appears in the compiled output, but doesn't verify the if: condition on that step. If the conditional (GH_AW_MAX_DAILY_AI_CREDITS != '') is wrong or absent, the token step would run unconditionally and fail in repos that don't set the guardrail — a regression this test would miss.
💡 Suggested assertion
// Verify the mint step is gated on the guardrail env var being set
if !strings.Contains(lockStr, "if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}") {
t.Fatal("expected mint step to be gated on guardrail env var")
}@copilot please address this.
| } | ||
| } | ||
|
|
||
| func TestMaxDailyAICObjectForm(t *testing.T) { |
There was a problem hiding this comment.
[/tdd] There's no test for the object form disabled case (value: -1 in object form). The scalar isMaxDailyAICDisabled was updated to go through extractMaxDailyAICObjectValue, but there's no regression guard for the object form of -1.
💡 Suggested test
t.Run("object form with value -1 is treated as disabled", func(t *testing.T) {
got := resolveMaxDailyAIC(map[string]any{
"max-daily-ai-credits": map[string]any{
"value": -1,
},
}, "")
if got != nil {
t.Fatalf("expected nil (disabled) for object form value -1, got %v", got)
}
})@copilot please address this.
| // and returned. When the object form lacks a "value" key, nil is returned so | ||
| // that downstream callers treat the limit as unset and fall back to imported or | ||
| // default values. When the value is not in object form, it is returned unchanged. | ||
| func extractMaxDailyAICObjectValue(raw any) any { |
There was a problem hiding this comment.
[/codebase-design] extractMaxDailyAICObjectValue returns nil when the raw value is an object without a value key — but callers like parseMaxDailyAICValue and isMaxDailyAICDisabled receive nil and silently treat the limit as unset. This means a malformed object form ({github-app: {...}} with no value) produces no validation error and no guardrail, which may surprise users.
💡 Suggestion
Consider returning a sentinel or threading a validation error up through validateMaxDailyAICFrontmatter when the object form is present but missing value. The JSON schema already enforces required: ["value"], but runtime parsing is separate. A clear error like "max-daily-ai-credits object form requires a 'value' field" would be more actionable than silent no-op.
@copilot please address this.
|
@copilot please run the
|
…ription
- Remove unused `data *WorkflowData` parameter from buildDailyAICAppTokenMintStep
- Fix resolveDailyAICToken ignore-if-missing fallback to use resolveActivationToken
instead of hardcoded ${{ secrets.GITHUB_TOKEN }}, consistent with the function's
documented behaviour
- Clarify schema description for object-form `value` to state that disabling via
-1 requires the scalar form
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Addressed the three review issues in commit
Local |
The
max-daily-ai-creditsguardrail reuses the activation token (orGITHUB_TOKEN) for its API calls, which depletes credits from the primary app. This adds an object form that lets you specify a dedicated GitHub App for those calls.New syntax
The scalar form (
max-daily-ai-credits: 10000) is unchanged.Changes
daily_aic_workflow.go—extractMaxDailyAICObjectValueunwrapsvaluefrom the object form;extractMaxDailyAICGitHubAppextracts the app config.isMaxDailyAICDisabled,resolveMaxDailyAICFromRaw, andvalidateMaxDailyAICFrontmatterupdated to handle the object form.workflow_data.go— NewMaxDailyAICreditsGitHubApp *GitHubAppConfigfield.workflow_builder.go— PopulatesMaxDailyAICreditsGitHubAppfrom frontmatter.compiler_activation_job_builder.go—buildDailyAICAppTokenMintStepemits adaily-aic-app-tokenstep (gated onGH_AW_MAX_DAILY_AI_CREDITS != '', requestsactions: read).resolveDailyAICTokenuses the dedicated token when configured, falling back to the activation token.main_workflow_schema.json— Object form (requiredvalue, optionalgithub-app) added tomax_daily_ai_credits_limit.