Summary
When an agentic workflow reaches a repository through a GitHub organization-level
required workflow (ruleset-injected), the daily AI Credits guardrail runs, 404s on
its own run-history query, and fails open — on every single run, permanently.
The guardrail is not being skipped by shouldSkipDailyAICGuardrail(); the event name
is pull_request, so that check correctly declines to short-circuit. The failure is
one call later: the run's workflow_id is not resolvable within the repository the
run executes in, so listWorkflowRuns returns 404, the top-level catch treats it as
a transient fault, and daily_ai_credits_exceeded stays at its default "false".
Net effect: the daily credit ceiling does not exist for this deployment model, and
the only signal is a warning worded to look like a passing network hiccup.
Impact
- The daily AIC ceiling never applies. Setting
GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS
has no effect — the log below shows threshold: 5000 resolved correctly and then
discarded when the query fails.
- No headroom telemetry is produced, so the summary table that would normally disclose
24h spend, threshold used %, and remaining headroom is never written.
- A permanent, structural failure is reported through the channel reserved for
transient ones, so it is indistinguishable from noise in run logs.
- This is precisely the deployment model that most needs a ceiling: one agentic review
workflow rolled out across many repositories, where no single repository owner is
watching aggregate spend.
The per-run cap (apiProxy.maxAiCredits) is unaffected — it is written into the
firewall config inside the agent job and does not depend on this query. So a single
run is still bounded, but the number of runs per day is not bounded at all.
Production evidence
Three consecutive runs, 2026-08-03 at 07:14, 07:25 and 07:36 UTC, byte-identical:
[daily-workflow-aic] Resolved current workflow AI Credits guardrail context:
{"owner":"ORG","repo":"target-repo","currentRunId":30794198089,
"workflowId":324645976,"workflowName":"PR Quality Review",
"threshold":5000,"rateLimitRemaining":4992,"rateLimitLimit":5000}
[daily-workflow-aic] Querying completed workflow runs:
{"workflowId":324645976,"page":1,"perPage":100,"cutoff":"2026-08-02T07:36:35.516Z"}
##[warning]Daily workflow AI Credits guardrail encountered an unexpected error
and will be skipped: Not Found -
https://docs.github.com/rest/actions/workflow-runs#list-workflow-runs-for-a-workflow
Why the 404 — the workflow is not a member of the repository's workflow collection:
$ gh api /repos/ORG/target-repo/contents/.github/workflows
ci.yml
release.yml
$ gh api /repos/ORG/target-repo/actions/workflows
318081782 CI .github/workflows/ci.yml [active]
318081783 Release .github/workflows/release.yml [active]
The repository contains no agentic workflow file and lists two workflows, yet the runs
execute under workflow_id: 324645976. That id resolves in neither the target
repository nor the repository that defines the workflow — which is characteristic of
ruleset-injected required workflows: they execute in the target repo's context
without being members of its workflow collection.
Root cause
actions/setup/js/check_daily_aic_workflow_guardrail.cjs, in main():
const { owner, repo } = context.repo; // the repo the run executes in
const currentRun = await githubClient.rest.actions.getWorkflowRun({
owner, repo, run_id: context.runId, // OK — the RUN is in this repo
});
// ...
const response = await githubClient.rest.actions.listWorkflowRuns({
owner, repo, // this repo...
workflow_id: currentRun.data.workflow_id, // ...but a workflow that is not in it
status: "completed", per_page: 100, page,
}); // -> 404 Not Found
Swallowed by the top-level handler:
} catch (error) {
// Treat unexpected guardrail execution errors as non-blocking skips so transient
// API/runtime issues do not fail activation. The output stays at the default "false",
// allowing the agent to run. Legitimate threshold exceedance still fails via setFailed.
core.warning(`Daily workflow AI Credits guardrail encountered an unexpected error and will be skipped: ${getErrorMessage(error)}`);
}
That comment describes a sound policy for a transient fault. A 404 here is neither
transient nor unexpected — it will recur on every run for the lifetime of the
deployment.
Reproduction
Attached test file: actions/setup/js/check_daily_aic_workflow_guardrail.required_workflow.test.cjs
$ cd actions/setup/js && npx vitest run check_daily_aic_workflow_guardrail
Test Files 1 failed | 1 passed (2)
Tests 1 failed | 37 passed (38)
Three cases. The existing 35 tests are untouched and still pass.
| Test |
State |
Establishes |
REPRO: a permanent 404 on run history silently disables the daily ceiling |
passes |
the bug |
CONTRAST: a transient 500 should fail open |
passes |
fail-open on real transients is correct and must be preserved |
REGRESSION: a permanent 404 must be distinguishable from a transient failure |
fails |
the defect |
The mock encodes the exact asymmetry that required workflows create:
// Succeeds: the RUN does live in this repo.
getWorkflowRun: async () => ({ data: { workflow_id: 324645976, ... } }),
// Fails: the WORKFLOW does not live in this repo.
listWorkflowRuns: async () => { throw httpError(404, "Not Found"); },
The repro runs with GH_AW_MAX_DAILY_AI_CREDITS = "10" — a deliberately absurd
ceiling — and still asserts daily_ai_credits_exceeded === "false" with setFailed
never called. A limit of ten credits cannot fire.
Note: GITHUB_EVENT_NAME is set to pull_request in the test, matching production.
shouldSkipDailyAICGuardrail() correctly does not short-circuit; reaching the
catch proves the guardrail genuinely ran and then failed.
One caveat in the interest of accuracy: the 404 in the mock is synthesized from the
production log message rather than captured from a live Octokit rejection. The status
code and message match what GitHub returned, and the code path only reads the message
via getErrorMessage, but a recorded fixture would be strictly more faithful.
Full test file — actions/setup/js/check_daily_aic_workflow_guardrail.required_workflow.test.cjs
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Repro + regression coverage for the daily AI Credits guardrail under
* GitHub organization-level *required workflows*.
*
* Background
* ----------
* A required workflow is injected by an org ruleset. It EXECUTES in the target
* repository's context, but it is NOT a member of that repository's workflow
* collection. Observed in production:
*
* $ gh api /repos/OWNER/REPO/actions/workflows
* 318081782 CI .github/workflows/ci.yml
* 318081783 Release .github/workflows/release.yml
*
* ...yet review runs execute under workflow_id 324645976, which is in neither
* that list nor the defining repo's list.
*
* main() reads the workflow id off the current run and then queries run history
* scoped to context.repo:
*
* const { owner, repo } = context.repo;
* const currentRun = await ...getWorkflowRun({ owner, repo, run_id: context.runId });
* const response = await ...listWorkflowRuns({ owner, repo,
* workflow_id: currentRun.data.workflow_id, ... });
*
* The second call 404s permanently, the top-level catch treats it as transient,
* and daily_ai_credits_exceeded stays "false" -- so the ceiling never applies.
*
* Real log line, identical on three consecutive runs (2026-08-03 07:14/07:25/07:36 UTC):
*
* [daily-workflow-aic] Querying completed workflow runs: {"workflowId":324645976,...}
* ##[warning]Daily workflow AI Credits guardrail encountered an unexpected
* error and will be skipped: Not Found
*/
let exports;
/** Octokit surfaces HTTP failures as an error carrying a numeric `status`. */
function httpError(status, message) {
const err = new Error(message);
err.status = status;
return err;
}
function makeCore() {
const outputs = {};
const warnings = [];
const errors = [];
return {
outputs,
warnings,
errors,
failures: [],
core: {
setOutput: (k, v) => {
outputs[k] = v;
},
info: () => {},
warning: m => warnings.push(String(m)),
error: m => errors.push(String(m)),
setFailed: function (m) {
this._failures.push(String(m));
},
_failures: [],
summary: {
addDetails: function () {
return this;
},
addRaw: function () {
return this;
},
write: async () => {},
},
},
};
}
/**
* A guardrail run where the workflow executes in the repo but its id is not
* resolvable there -- i.e. an org-level required workflow.
*
* @param {Error} listError error thrown by listWorkflowRuns
*/
function makeGithub(listError) {
return {
rest: {
rateLimit: {
get: async () => ({
data: {
resources: {
core: {
limit: 5000,
remaining: 4992,
used: 8,
reset: Math.floor(Date.UTC(2026, 7, 3, 9) / 1000),
},
},
},
headers: {},
}),
},
actions: {
// Succeeds: the RUN does live in this repo.
getWorkflowRun: async () => ({
data: {
workflow_id: 324645976,
actor: { login: "octocat" },
triggering_actor: { login: "octocat" },
},
headers: {},
}),
// Fails: the WORKFLOW does not live in this repo.
listWorkflowRuns: async () => {
throw listError;
},
},
},
};
}
describe("daily AIC guardrail under org-level required workflows", () => {
beforeEach(async () => {
vi.resetModules();
// The production event name really is pull_request: the required-workflow
// stub triggers on pull_request, so shouldSkipDailyAICGuardrail() does NOT
// short-circuit. The guardrail genuinely runs and then fails.
process.env.GITHUB_EVENT_NAME = "pull_request";
process.env.GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT = "";
process.env.GH_AW_HAS_SLASH_COMMAND = "false";
process.env.GH_AW_HAS_LABEL_COMMAND = "false";
process.env.GH_AW_GITHUB_TOKEN = "fake-token";
process.env.GH_AW_WORKFLOW_NAME = "PR Quality Review";
// A deliberately tight ceiling: if the guardrail could evaluate at all,
// any real spend would breach it.
process.env.GH_AW_MAX_DAILY_AI_CREDITS = "10";
const mod = await import("./check_daily_aic_workflow_guardrail.cjs");
exports = mod.default || mod;
});
async function runGuardrail(listError) {
const h = makeCore();
global.core = h.core;
global.github = makeGithub(listError);
global.context = { repo: { owner: "test-org", repo: "target-repo" }, runId: 30794198089 };
try {
await expect(exports.main()).resolves.toBeUndefined();
} finally {
delete global.core;
delete global.github;
delete global.context;
}
return h;
}
// ---------------------------------------------------------------- repro ---
// PASSES on current code. This is the bug, not the fix.
it("REPRO: a permanent 404 on run history silently disables the daily ceiling", async () => {
const h = await runGuardrail(httpError(404, "Not Found"));
// The agent is allowed to run...
expect(h.outputs["daily_ai_credits_exceeded"]).toBe("false");
// ...and nothing failed the step, despite a ceiling of 10 AIC.
expect(h.core._failures).toEqual([]);
// The only trace is the generic transient-error warning.
expect(h.warnings.some(w => /unexpected error.*skipped/i.test(w))).toBe(true);
});
// PASSES on current code. Establishes that fail-open on a genuine transient
// fault is correct and must be preserved by any fix.
it("CONTRAST: a transient 500 should fail open (existing, desired behaviour)", async () => {
const h = await runGuardrail(httpError(500, "Internal Server Error"));
expect(h.outputs["daily_ai_credits_exceeded"]).toBe("false");
expect(h.warnings.some(w => /unexpected error.*skipped/i.test(w))).toBe(true);
});
// ----------------------------------------------------------- regression ---
// FAILS on current code. Passes once a permanent, structural failure is
// reported distinguishably from a transient one.
//
// The assertion is deliberately about the OBSERVABLE CONTRACT, not a specific
// implementation: "the guardrail could not evaluate" must not be reported
// through the same channel, and with the same output, as "the guardrail
// evaluated and you are under budget". Any distinguishable signal satisfies
// this -- core.error, a dedicated status output, or a non-"false" value.
it("REGRESSION: a permanent 404 must be distinguishable from a transient failure", async () => {
const structural = await runGuardrail(httpError(404, "Not Found"));
const transient = await runGuardrail(httpError(500, "Internal Server Error"));
const structuralSignal = JSON.stringify({
errors: structural.errors,
outputs: structural.outputs,
});
const transientSignal = JSON.stringify({
errors: transient.errors,
outputs: transient.outputs,
});
expect(structuralSignal).not.toEqual(transientSignal);
});
});
Suggested fix
Two parts, the second mattering more than the first.
-
Resolve run history in a way that survives required-workflow injection. When the
workflow_id lookup 404s, fall back to listing the repository's runs and filtering
by workflow name (already available as GH_AW_WORKFLOW_NAME).
-
Stop reporting a permanent failure as a transient one. A structural error that
disables a spend guard for the lifetime of a deployment should be distinguishable
from a network blip — core.error, a dedicated status output, or any signal that
separates "could not evaluate" from "evaluated, under budget". Today those two
states are byte-identical to a consumer, which is what turns this from a bug into an
unbounded bill.
The attached regression test asserts only the contract in (2), not any particular
implementation, so it should pass under whichever mechanism you prefer.
Environment
- gh-aw
v0.83.4 (compiler + github/gh-aw-actions/setup@v0.83.4) in production
- Reproduced against
main at bd16174
- Engine: codex /
gpt-5.6-sol; self-hosted runners
- Distribution: org ruleset injects a stub that calls the compiled workflow via
workflow_call; target repositories contain no agentic workflow files
Summary
When an agentic workflow reaches a repository through a GitHub organization-level
required workflow (ruleset-injected), the daily AI Credits guardrail runs, 404s on
its own run-history query, and fails open — on every single run, permanently.
The guardrail is not being skipped by
shouldSkipDailyAICGuardrail(); the event nameis
pull_request, so that check correctly declines to short-circuit. The failure isone call later: the run's
workflow_idis not resolvable within the repository therun executes in, so
listWorkflowRunsreturns 404, the top-levelcatchtreats it asa transient fault, and
daily_ai_credits_exceededstays at its default"false".Net effect: the daily credit ceiling does not exist for this deployment model, and
the only signal is a warning worded to look like a passing network hiccup.
Impact
GH_AW_DEFAULT_MAX_DAILY_AI_CREDITShas no effect — the log below shows
threshold: 5000resolved correctly and thendiscarded when the query fails.
24h spend, threshold used %, and remaining headroom is never written.
transient ones, so it is indistinguishable from noise in run logs.
workflow rolled out across many repositories, where no single repository owner is
watching aggregate spend.
The per-run cap (
apiProxy.maxAiCredits) is unaffected — it is written into thefirewall config inside the agent job and does not depend on this query. So a single
run is still bounded, but the number of runs per day is not bounded at all.
Production evidence
Three consecutive runs, 2026-08-03 at 07:14, 07:25 and 07:36 UTC, byte-identical:
Why the 404 — the workflow is not a member of the repository's workflow collection:
The repository contains no agentic workflow file and lists two workflows, yet the runs
execute under
workflow_id: 324645976. That id resolves in neither the targetrepository nor the repository that defines the workflow — which is characteristic of
ruleset-injected required workflows: they execute in the target repo's context
without being members of its workflow collection.
Root cause
actions/setup/js/check_daily_aic_workflow_guardrail.cjs, inmain():Swallowed by the top-level handler:
That comment describes a sound policy for a transient fault. A 404 here is neither
transient nor unexpected — it will recur on every run for the lifetime of the
deployment.
Reproduction
Attached test file:
actions/setup/js/check_daily_aic_workflow_guardrail.required_workflow.test.cjsThree cases. The existing 35 tests are untouched and still pass.
REPRO: a permanent 404 on run history silently disables the daily ceilingCONTRAST: a transient 500 should fail openREGRESSION: a permanent 404 must be distinguishable from a transient failureThe mock encodes the exact asymmetry that required workflows create:
The repro runs with
GH_AW_MAX_DAILY_AI_CREDITS = "10"— a deliberately absurdceiling — and still asserts
daily_ai_credits_exceeded === "false"withsetFailednever called. A limit of ten credits cannot fire.
Note:
GITHUB_EVENT_NAMEis set topull_requestin the test, matching production.shouldSkipDailyAICGuardrail()correctly does not short-circuit; reaching thecatchproves the guardrail genuinely ran and then failed.One caveat in the interest of accuracy: the 404 in the mock is synthesized from the
production log message rather than captured from a live Octokit rejection. The status
code and message match what GitHub returned, and the code path only reads the message
via
getErrorMessage, but a recorded fixture would be strictly more faithful.Full test file —
actions/setup/js/check_daily_aic_workflow_guardrail.required_workflow.test.cjsSuggested fix
Two parts, the second mattering more than the first.
Resolve run history in a way that survives required-workflow injection. When the
workflow_idlookup 404s, fall back to listing the repository's runs and filteringby workflow name (already available as
GH_AW_WORKFLOW_NAME).Stop reporting a permanent failure as a transient one. A structural error that
disables a spend guard for the lifetime of a deployment should be distinguishable
from a network blip —
core.error, a dedicated status output, or any signal thatseparates "could not evaluate" from "evaluated, under budget". Today those two
states are byte-identical to a consumer, which is what turns this from a bug into an
unbounded bill.
The attached regression test asserts only the contract in (2), not any particular
implementation, so it should pass under whichever mechanism you prefer.
Environment
v0.83.4(compiler +github/gh-aw-actions/setup@v0.83.4) in productionmainatbd16174gpt-5.6-sol; self-hosted runnersworkflow_call; target repositories contain no agentic workflow files