Repository navigation
Description
Environment
otterdog 1.5.0, CLI (otterdog apply --local --no-web-ui --force), authenticating
with an installation access token minted for a GitHub App
(OTTERDOG_TOKEN). The organization is on GitHub Team; the repository is private.
What happens
A repository ruleset declaring a GitHub App as a bypass actor:
orgs.newRepoRuleset('<name>') {
allows_creations: false,
bypass_actors+: ['<app-slug>'],
include_refs+: ['refs/tags/*/v*'],
target: 'tag',
}validate and plan both pass and render the change as expected. apply then
fails, atomically — nothing is written:
Error: failed to apply patch: CHANGE - repo_ruleset[name="<name>", repository=<repo>]
failed retrieving app node id:
Exception while accessing 'https://api.github.com/apps/<app-slug>':
(status=403, body={"message":"Resource not accessible by integration", ...})
Cause
On the write path, models/ruleset.py treats any bypass actor that is
neither #role nor @team as an App slug and resolves it through
rest_api.app.get_app_ids(slug) → GET /apps/{app_slug}. That endpoint is not
available to installation access tokens; it needs a JWT or a user token.
The read path in the same file takes app_slug straight from the ruleset
API response and makes no second call, so importing, plan and drift detection
all work. Only the first write of an App bypass actor fails — which is why an
App bypass actor that was already live when it was declared never trips this,
and a removal also works, since only the actors being written are resolved.
providers/github/__init__.py constructs the provider with
token_auth(self._credentials.github_token); the app_auth JWT strategy is not
used on this path and credential providers only yield a token, so a caller
cannot work around this by supplying App credentials instead.
models/branch_protection_rule.py resolves app node ids the same way
(get_app_node_ids), so branch protection rules with App bypass actors look
like the same class of problem.
Impact
Any App bypass actor is unwritable under installation-token auth, and because
the failure is at write time, plan offers no warning: a change reviewed green
fails mid-apply against the live organization. Once such a ruleset exists (it
can be created out of band through the REST API), every subsequent change to it
hits the same failure, because a ruleset patch rewrites its whole bypass list —
including otterdog's own reconciliation of a manual edit to it.
Precedent
This is the same class of defect as #695, on the other half of the model. There,
a required status check reported only by integration_id was resolved as an App
slug and GET /apps/<id> 404'd; the fix was to accept a digits-only token and
map it straight to integration_id, which is why
required_status_checks now handles numeric ids in models/ruleset.py.
Bypass actors never got the equivalent, so they still take the slug path and
still need a credential class the CLI cannot obtain. (#732, still open, is a
third variant of the same resolution problem on the read side.)
Possible fixes
- Accept a numeric app id for bypass actors, exactly as
applycannot patch a ruleset whose required status check is reported only byintegration_id—importemits a numeric<id>:<context>token thatapplyresolves as an app *slug* →GET /apps/<id>404s #695 did for status
checks. The REST API takesactor_iddirectly, so no lookup is needed when
the id is given, and no credential question arises. - Resolve the slug through an endpoint an installation token can read — the
organization's installations listing would do it where the credential
carries the necessary organization permission — falling back to
GET /apps/{app_slug}otherwise. - Failing either, detect the unresolvable actor during
plan, so the failure
surfaces at review time rather than part-way through a live apply.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsBacklog
Activity
github-actions commentedon Sep 24, 2026
Summary: GitHub App bypass actors on rulesets (and branch protection rules) fail to apply when otterdog authenticates with an installation access token, because the write path resolves app slugs via
GET /apps/{slug}, an endpoint installation tokens cannot access.Label changes: added
type:bug(confirmed defect),component:github-api(REST API app resolution),component:configuration(ruleset/branch-protection model),severity:high(write fails with no workaround, mid-apply),priority:p1(affects any org using App bypass actors with installation-token auth),effort:small(mirrors existing digits-only fix from #695),ai-triaged.Feasibility & impact:
otterdog/models/ruleset.py(get_mapping_to_provider, bypass actor resolution) andotterdog/models/branch_protection_rule.py(get_app_node_ids); root cause inproviders/githubapp-slug lookup.otterdog.json/Jsonnet rulesets and branch protection rules declaring App bypass actors; existing configs need no schema change, but a code fix is required beforeapplysucceeds for these entries.applycannot patch a ruleset whose required status check is reported only byintegration_id—importemits a numeric<id>:<context>token thatapplyresolves as an app *slug* →GET /apps/<id>404s #695) for the analogous status-check case provides a clear pattern (accept numeric app id directly, skip slug lookup).Potential duplicates: none found; related but distinct:
applycannot patch a ruleset whose required status check is reported only byintegration_id—importemits a numeric<id>:<context>token thatapplyresolves as an app *slug* →GET /apps/<id>404s #695 (status checks, already fixed), Status checks bound to non-installed apps (e.g. first-party github-actions) always render numeric on read, making slug-form config a permanent plan diff #732 (read-side app resolution, still open).docs(rulesets): record that an App bypass actor cannot be created by …
docs(rulesets): record that an App bypass actor cannot be created by …
feat(plan): warn when the engine token cannot resolve a declared App …
docs(otterdog): refresh the App-installation evidence for the 1.6.1 p…
chore: release 2.0.0 (#309)
c-vigo commentedon Oct 4, 2026
One correction to the Cause section, and an argument for fix 2 over fix 1.
GET /apps/{app_slug}is readable by an installation token — for a public AppThe issue says the endpoint "is not available to installation access tokens; it
needs a JWT or a user token". That is too strong, and the difference changes the
scope of the bug. The determinant is the App's visibility, not the credential
class:
200200404403Measured on two App bypass actors declared in the same organization, resolved
with the same installation token on the same run: the public one resolves and
applies fine, the private one 403s. So:
presumably why this has gone unreported for so long;
severity:hightriage note — publishthe App. It is frequently unacceptable (a public App discloses its name and
permission set and can be installed by anyone, while the owner keeps the
private key), but it exists and is worth recording;
Fix 2 is nearly free: otterdog already builds the exact mapping
GET /orgs/{org}/installationsis not a new call on a new credential — theread path already makes it on every run, with the installation token, and the
issue's own observation that "importing,
planand drift detection all work" isthe proof that the token can read it.
models/github_organization.py(currentmain):…and the bypass-actor read path then does
actor["app_slug"] = app_installations[actor_id].That dict is keyed
str(app_id) -> app_slug, andapp_idis exactly theactor_idthe rulesets API wants. Fix 2 is inverting a dict otterdog alreadyhas, for private and public Apps alike, with
GET /apps/{app_slug}left as thefallback for an App that is not an installation on the org.
Fix 1 alone would trade a hard failure for a permanent diff
Accepting a digits-only bypass actor (as #695 did for status checks) is not
sufficient on its own. The read path resolves a live bypass actor back to a
slug through the map above, so config holding
12345would be comparedagainst a live
my-appon every plan — a standing phantom diff, for a value theuser cannot express any other way. Fix 1 would need comparison normalization on
the read side as well; fix 2 keeps config in slugs and never raises the question.
(Fix 3 is a worthwhile stopgap and we run it downstream — a plan-time probe of
every declared App-shaped actor with the credential
applywill use, so thefailure surfaces at review instead of mid-apply. It does not remove the need for
1 or 2: it only moves the discovery earlier.)
Happy to open a PR for fix 2 if that direction is acceptable.