Summary
checkRepositoryPermission in actions/setup/js/check_permissions_utils.cjs reads a inherited_role field from the response of GET /repos/{owner}/{repo}/collaborators/{username}/permission. That field does not exist in the GitHub REST API.
As a result, the custom-org-role fallback introduced in #45641 is dead code in production, and every user whose highest role is a custom organization repository role is unconditionally denied, even when their effective base permission clearly satisfies on.roles.
Reproduction
A user with the custom org role Project Lead (base permission write) triggering a workflow with roles: [admin, maintainer, write]:
Checking if user 'octocat' has required permissions for example-org/example-repo
Required permissions: admin, maintainer, write
Repository permission level: write (role: Project Lead)
Warning: User permission 'Project Lead' does not meet requirements: admin, maintainer, write
The user has write, but is denied.
Root cause analysis
1. The API never returns inherited_role
Live response for the affected user:
$ gh api repos/example-org/example-repo/collaborators/octocat/permission
{
"permission": "write",
"user": {
"login": "octocat",
...
"permissions": {
"admin": false,
"maintain": false,
"push": true,
"triage": true,
"pull": true
},
"role_name": "Project Lead"
},
"role_name": "Project Lead"
}
No inherited_role. This matches the documented schema for Get repository permissions for a user, whose only top-level properties are permission, role_name, and user:
{
"title": "Repository Collaborator Permission",
"type": "object",
"properties": {
"permission": { "type": "string" },
"role_name": { "type": "string" },
"user": { "anyOf": [ { "type": "null" }, { "title": "Collaborator", ... } ] }
},
"required": ["permission", "role_name", "user"]
}
The name inherited_role appears to be a confusion with base_role, which exists only on the custom repository role object returned by GET /orgs/{org}/custom-repository-roles — a different endpoint.
2. The dead branch
In checkRepositoryPermission (actions/setup/js/check_permissions_utils.cjs):
const rawInheritedRole = repoPermissionData.inherited_role;
const inheritedRole = rawInheritedRole == null ? "" : typeof rawInheritedRole === "string" ? rawInheritedRole : "";
...
const inheritedStandardRole = isCustomRole && STANDARD_ROLES.has(normalizedInheritedRole) ? normalizedInheritedRole : "";
rawInheritedRole is always undefined, so inheritedStandardRole is always "". The match loop only ever compares required roles against effectiveRole (the raw custom role name) and inheritedStandardRole (always empty) — so a custom role can only ever be authorized by listing its literal name in on.roles.
3. Why tests didn't catch it
actions/setup/js/check_permissions_utils.test.cjs hand-mocks the non-existent field, so the fallback appears to work:
data: { permission: "write", role_name: "Security Champions", inherited_role: "write" }
The tests assert against a fixture shape the API never produces.
Proposed fix
Resolve the custom role's real base role from the endpoint that actually publishes it: List custom repository roles in an organization.
GET /orgs/{org}/custom-repository-roles
Its response contains exactly the field the current code is looking for, under its real name:
{
"total_count": 1,
"custom_roles": [
{
"id": 8030,
"name": "Project Lead",
"base_role": "write",
"permissions": ["..."],
"organization": { "login": "example-org", "...": "..." },
"created_at": "...",
"updated_at": "..."
}
]
}
base_role is required in the schema and constrained to the enum read | triage | write | maintain — so once the role is found, the mapping into STANDARD_ROLES is total and needs no guessing. This is a strictly better source than the legacy permission string, which the collaborator docs state collapses maintain→write and triage→read, and it preserves the security property that motivated #45641: never over-grant from a coarse field.
Note base_role cannot be admin — the enum tops out at maintain. A custom role therefore can never satisfy roles: [admin], which is correct behaviour and should be asserted in tests.
Implementation plan
-
actions/setup/js/check_permissions_utils.cjs — in checkRepositoryPermission:
- Remove
rawInheritedRole / inheritedRole / normalizedInheritedRole and the inherited_role entry in the @type annotation.
- Add
resolveCustomRoleBaseRole(org, roleName) that calls github.rest.orgs.listCustomRepoRoles({ org }), matches custom_roles[].name against role_name case-insensitively, and returns the normalized base_role (maintain stays maintain). Return "" when the role is not found or the request fails.
- Replace
inheritedStandardRole with this resolved value, keeping the existing isCustomRole guard so the lookup applies only to custom roles (standard roles keep matching on role_name).
- Keep failing closed when the resolved role is
"".
- Update the debug lines to report the resolved
base_role instead of inherited=, so the decision stays traceable under ACTIONS_RUNNER_DEBUG=true.
- Update the explanatory comment above the match loop, which currently references "inherited standard role from custom-role metadata".
-
actions/setup/js/check_permissions_utils.test.cjs — rewrite the custom-role tests against realistic fixtures:
- Replace every
inherited_role: "..." mock with a listCustomRepoRoles mock returning { total_count, custom_roles: [{ name, base_role }] }.
- Add a regression test using the real-world payload above (
permission: "write", a custom role_name) resolved via base_role: "write", asserting authorized: true for ["admin", "maintain", "write"].
- Add a test where
base_role is maintain and assert authorization for ["maintain"] but denial for ["admin"].
- Assert case-insensitive matching of
role_name against custom_roles[].name.
- Keep the fail-closed test, with the role absent from the returned list.
- Retain existing coverage for: custom role authorized by literal name in
on.roles; empty role_name not treated as a custom role.
-
Verification — confirm no other call site or compiled artifact references the removed field:
grep -rn "inherited_role\|inheritedRole" .
-
Run make agent-finish.
Impact
Any organization using custom repository roles cannot use on.roles gating at all — the workflow silently refuses to run for legitimate maintainers. The only current workaround is to use all in on.roles.
Environment
Summary
checkRepositoryPermissioninactions/setup/js/check_permissions_utils.cjsreads ainherited_rolefield from the response ofGET /repos/{owner}/{repo}/collaborators/{username}/permission. That field does not exist in the GitHub REST API.As a result, the custom-org-role fallback introduced in #45641 is dead code in production, and every user whose highest role is a custom organization repository role is unconditionally denied, even when their effective base permission clearly satisfies
on.roles.Reproduction
A user with the custom org role
Project Lead(base permissionwrite) triggering a workflow withroles: [admin, maintainer, write]:The user has
write, but is denied.Root cause analysis
1. The API never returns
inherited_roleLive response for the affected user:
No
inherited_role. This matches the documented schema for Get repository permissions for a user, whose only top-level properties arepermission,role_name, anduser:{ "title": "Repository Collaborator Permission", "type": "object", "properties": { "permission": { "type": "string" }, "role_name": { "type": "string" }, "user": { "anyOf": [ { "type": "null" }, { "title": "Collaborator", ... } ] } }, "required": ["permission", "role_name", "user"] }The name
inherited_roleappears to be a confusion withbase_role, which exists only on the custom repository role object returned byGET /orgs/{org}/custom-repository-roles— a different endpoint.2. The dead branch
In
checkRepositoryPermission(actions/setup/js/check_permissions_utils.cjs):rawInheritedRoleis alwaysundefined, soinheritedStandardRoleis always"". The match loop only ever compares required roles againsteffectiveRole(the raw custom role name) andinheritedStandardRole(always empty) — so a custom role can only ever be authorized by listing its literal name inon.roles.3. Why tests didn't catch it
actions/setup/js/check_permissions_utils.test.cjshand-mocks the non-existent field, so the fallback appears to work:The tests assert against a fixture shape the API never produces.
Proposed fix
Resolve the custom role's real base role from the endpoint that actually publishes it: List custom repository roles in an organization.
Its response contains exactly the field the current code is looking for, under its real name:
{ "total_count": 1, "custom_roles": [ { "id": 8030, "name": "Project Lead", "base_role": "write", "permissions": ["..."], "organization": { "login": "example-org", "...": "..." }, "created_at": "...", "updated_at": "..." } ] }base_roleisrequiredin the schema and constrained to the enumread | triage | write | maintain— so once the role is found, the mapping intoSTANDARD_ROLESis total and needs no guessing. This is a strictly better source than the legacypermissionstring, which the collaborator docs state collapsesmaintain→writeandtriage→read, and it preserves the security property that motivated #45641: never over-grant from a coarse field.Note
base_rolecannot beadmin— the enum tops out atmaintain. A custom role therefore can never satisfyroles: [admin], which is correct behaviour and should be asserted in tests.Implementation plan
actions/setup/js/check_permissions_utils.cjs— incheckRepositoryPermission:rawInheritedRole/inheritedRole/normalizedInheritedRoleand theinherited_roleentry in the@typeannotation.resolveCustomRoleBaseRole(org, roleName)that callsgithub.rest.orgs.listCustomRepoRoles({ org }), matchescustom_roles[].nameagainstrole_namecase-insensitively, and returns the normalizedbase_role(maintainstaysmaintain). Return""when the role is not found or the request fails.inheritedStandardRolewith this resolved value, keeping the existingisCustomRoleguard so the lookup applies only to custom roles (standard roles keep matching onrole_name)."".base_roleinstead ofinherited=, so the decision stays traceable underACTIONS_RUNNER_DEBUG=true.actions/setup/js/check_permissions_utils.test.cjs— rewrite the custom-role tests against realistic fixtures:inherited_role: "..."mock with alistCustomRepoRolesmock returning{ total_count, custom_roles: [{ name, base_role }] }.permission: "write", a customrole_name) resolved viabase_role: "write", assertingauthorized: truefor["admin", "maintain", "write"].base_roleismaintainand assert authorization for["maintain"]but denial for["admin"].role_nameagainstcustom_roles[].name.on.roles; emptyrole_namenot treated as a custom role.Verification — confirm no other call site or compiled artifact references the removed field:
Run
make agent-finish.Impact
Any organization using custom repository roles cannot use
on.rolesgating at all — the workflow silently refuses to run for legitimate maintainers. The only current workaround is to useallinon.roles.Environment
actions/setup/js/check_permissions_utils.cjswrite