Skip to content

Daily AI Credits guardrail permanently fails open under org-level required workflows #49952

Description

@alvistar

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.

  1. 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).

  2. 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

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions