Skip to content

Commit e37ffb3

Browse files
authored
feat: add per-permission-level repo exclusions to github-add-repo-permissions (#48)
Add REPO_ADMIN_EXCLUDE, REPO_MAINTAIN_EXCLUDE, REPO_PUSH_EXCLUDE, REPO_TRIAGE_EXCLUDE, and REPO_PULL_EXCLUDE variables to skip named repos for a given permission level only. Expose them as action.yml inputs, document in README, and add behavioral tests with a dedicated curl mock.
1 parent 942ccdb commit e37ffb3

5 files changed

Lines changed: 163 additions & 37 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ export REPO_PULL="external-auditors" # Read/pull permissions
165165

166166
# Optional: restrict to repos whose names start with a given prefix
167167
export REPO_NAME_FILTER="my-service-"
168+
169+
# Optional: skip specific repos for a given permission level (space-separated repo names)
170+
export REPO_PULL_EXCLUDE="secret-repo internal-tools" # These repos won't get pull access
168171
```
169172

170173
**Usage:**
@@ -178,6 +181,7 @@ cd org-admin/github-add-repo-permissions
178181
- Filters to repositories whose names start with `REPO_NAME_FILTER` (all repos when unset)
179182
- Grants permissions to specified teams based on permission level
180183
- Supports multiple teams per permission level (space-separated)
184+
- Skips repos listed in the matching `REPO_<LEVEL>_EXCLUDE` variable for that permission level only
181185
- Processes all five GitHub permission levels: admin, maintain, push, triage, pull
182186
- Includes 5-second delays between repos to avoid rate limits
183187

@@ -190,6 +194,8 @@ cd org-admin/github-add-repo-permissions
190194

191195
> [!NOTE]
192196
> At least one permission level must be set. Team slugs should be lowercase and hyphenated (e.g., "Platform Team" → `platform-team`).
197+
>
198+
> Each permission level has an optional exclusion list: `REPO_ADMIN_EXCLUDE`, `REPO_MAINTAIN_EXCLUDE`, `REPO_PUSH_EXCLUDE`, `REPO_TRIAGE_EXCLUDE`, and `REPO_PULL_EXCLUDE`. Each holds space-separated exact repo names that are skipped for that level only.
193199
194200
---
195201

org-admin/github-add-repo-permissions/action.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,26 @@ inputs:
3131
description: 'Space-separated team slugs to grant pull access'
3232
required: false
3333
default: ''
34+
repo-admin-exclude:
35+
description: 'Space-separated repo names to skip for admin access'
36+
required: false
37+
default: ''
38+
repo-maintain-exclude:
39+
description: 'Space-separated repo names to skip for maintain access'
40+
required: false
41+
default: ''
42+
repo-push-exclude:
43+
description: 'Space-separated repo names to skip for push access'
44+
required: false
45+
default: ''
46+
repo-triage-exclude:
47+
description: 'Space-separated repo names to skip for triage access'
48+
required: false
49+
default: ''
50+
repo-pull-exclude:
51+
description: 'Space-separated repo names to skip for pull access'
52+
required: false
53+
default: ''
3454
api-url-prefix:
3555
description: 'GitHub API base URL'
3656
required: false
@@ -49,5 +69,10 @@ runs:
4969
REPO_PUSH: ${{ inputs.repo-push }}
5070
REPO_TRIAGE: ${{ inputs.repo-triage }}
5171
REPO_PULL: ${{ inputs.repo-pull }}
72+
REPO_ADMIN_EXCLUDE: ${{ inputs.repo-admin-exclude }}
73+
REPO_MAINTAIN_EXCLUDE: ${{ inputs.repo-maintain-exclude }}
74+
REPO_PUSH_EXCLUDE: ${{ inputs.repo-push-exclude }}
75+
REPO_TRIAGE_EXCLUDE: ${{ inputs.repo-triage-exclude }}
76+
REPO_PULL_EXCLUDE: ${{ inputs.repo-pull-exclude }}
5277
API_URL_PREFIX: ${{ inputs.api-url-prefix }}
5378
run: ${{ github.action_path }}/github-add-repo-permissions.sh

org-admin/github-add-repo-permissions/github-add-repo-permissions.sh

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,24 @@
1515
# ./github-add-repo-permissions.sh
1616
#
1717
# Environment variables:
18-
# GITHUB_TOKEN Required. PAT with admin:org scope
19-
# ORG Required. GitHub organization name
20-
# REPO_NAME_FILTER Optional. Prefix filter for repository names (default: all repos)
21-
# REPO_ADMIN Optional. Space-separated team slugs to grant admin access
22-
# REPO_MAINTAIN Optional. Space-separated team slugs to grant maintain access
23-
# REPO_PUSH Optional. Space-separated team slugs to grant push access
24-
# REPO_TRIAGE Optional. Space-separated team slugs to grant triage access
25-
# REPO_PULL Optional. Space-separated team slugs to grant pull access
26-
# API_URL_PREFIX Optional. GitHub API base URL (default: https://api.github.com)
18+
# GITHUB_TOKEN Required. PAT with admin:org scope
19+
# ORG Required. GitHub organization name
20+
# REPO_NAME_FILTER Optional. Prefix filter for repository names (default: all repos)
21+
# REPO_ADMIN Optional. Space-separated team slugs to grant admin access
22+
# REPO_MAINTAIN Optional. Space-separated team slugs to grant maintain access
23+
# REPO_PUSH Optional. Space-separated team slugs to grant push access
24+
# REPO_TRIAGE Optional. Space-separated team slugs to grant triage access
25+
# REPO_PULL Optional. Space-separated team slugs to grant pull access
26+
# REPO_ADMIN_EXCLUDE Optional. Space-separated repo names to skip for admin access
27+
# REPO_MAINTAIN_EXCLUDE Optional. Space-separated repo names to skip for maintain access
28+
# REPO_PUSH_EXCLUDE Optional. Space-separated repo names to skip for push access
29+
# REPO_TRIAGE_EXCLUDE Optional. Space-separated repo names to skip for triage access
30+
# REPO_PULL_EXCLUDE Optional. Space-separated repo names to skip for pull access
31+
# API_URL_PREFIX Optional. GitHub API base URL (default: https://api.github.com)
2732
#
2833
# Note: At least one of REPO_ADMIN, REPO_MAINTAIN, REPO_PUSH, REPO_TRIAGE,
29-
# or REPO_PULL must be set.
34+
# or REPO_PULL must be set. Each REPO_*_EXCLUDE list names repositories
35+
# that are skipped for that permission level only.
3036
#
3137
# Requirements:
3238
# - curl
@@ -52,6 +58,13 @@ REPO_PUSH=${REPO_PUSH:-''}
5258
REPO_TRIAGE=${REPO_TRIAGE:-''}
5359
REPO_PULL=${REPO_PULL:-''}
5460

61+
# Permission-specific exclusion lists (space-separated repo names to skip)
62+
REPO_ADMIN_EXCLUDE=${REPO_ADMIN_EXCLUDE:-''}
63+
REPO_MAINTAIN_EXCLUDE=${REPO_MAINTAIN_EXCLUDE:-''}
64+
REPO_PUSH_EXCLUDE=${REPO_PUSH_EXCLUDE:-''}
65+
REPO_TRIAGE_EXCLUDE=${REPO_TRIAGE_EXCLUDE:-''}
66+
REPO_PULL_EXCLUDE=${REPO_PULL_EXCLUDE:-''}
67+
5568
require_env_var GITHUB_TOKEN "GitHub token"
5669
require_env_var ORG "GitHub organization"
5770
require_command jq
@@ -68,6 +81,35 @@ if [ -n "${REPO_NAME_FILTER}" ]; then
6881
print_status "Repository filter: ${REPO_NAME_FILTER}*"
6982
fi
7083

84+
is_excluded () {
85+
local REPO_NAME=$1
86+
local EXCLUDE_LIST=$2
87+
local EXCLUDED
88+
89+
for EXCLUDED in ${EXCLUDE_LIST}; do
90+
if [ "${EXCLUDED}" = "${REPO_NAME}" ]; then
91+
return 0
92+
fi
93+
done
94+
return 1
95+
}
96+
97+
apply_level () {
98+
local REPO_NAME=$1
99+
local PERMISSION=$2
100+
local TEAM_SLUGS=$3
101+
local EXCLUDE_LIST=$4
102+
103+
[ -z "${TEAM_SLUGS}" ] && return 0
104+
105+
if is_excluded "${REPO_NAME}" "${EXCLUDE_LIST}"; then
106+
print_status " Skipping ${PERMISSION} on ${REPO_NAME} (excluded)"
107+
return 0
108+
fi
109+
110+
apply_team_permissions "${REPO_NAME}" "${PERMISSION}" "${TEAM_SLUGS}"
111+
}
112+
71113
apply_team_permissions () {
72114
local REPO_NAME=$1
73115
local PERMISSION=$2
@@ -107,32 +149,13 @@ process_repos () {
107149
while IFS= read -r REPO; do
108150
[ -z "${REPO}" ] && continue
109151
print_status "Processing repo ${REPO}"
110-
111-
# Apply admin permissions
112-
if [ -n "${REPO_ADMIN}" ]; then
113-
apply_team_permissions "${REPO}" "admin" "${REPO_ADMIN}"
114-
fi
115-
116-
# Apply maintain permissions
117-
if [ -n "${REPO_MAINTAIN}" ]; then
118-
apply_team_permissions "${REPO}" "maintain" "${REPO_MAINTAIN}"
119-
fi
120-
121-
# Apply push (write) permissions
122-
if [ -n "${REPO_PUSH}" ]; then
123-
apply_team_permissions "${REPO}" "push" "${REPO_PUSH}"
124-
fi
125-
126-
# Apply triage permissions
127-
if [ -n "${REPO_TRIAGE}" ]; then
128-
apply_team_permissions "${REPO}" "triage" "${REPO_TRIAGE}"
129-
fi
130-
131-
# Apply pull (read) permissions
132-
if [ -n "${REPO_PULL}" ]; then
133-
apply_team_permissions "${REPO}" "pull" "${REPO_PULL}"
134-
fi
135-
152+
153+
apply_level "${REPO}" "admin" "${REPO_ADMIN}" "${REPO_ADMIN_EXCLUDE}"
154+
apply_level "${REPO}" "maintain" "${REPO_MAINTAIN}" "${REPO_MAINTAIN_EXCLUDE}"
155+
apply_level "${REPO}" "push" "${REPO_PUSH}" "${REPO_PUSH_EXCLUDE}"
156+
apply_level "${REPO}" "triage" "${REPO_TRIAGE}" "${REPO_TRIAGE_EXCLUDE}"
157+
apply_level "${REPO}" "pull" "${REPO_PULL}" "${REPO_PULL_EXCLUDE}"
158+
136159
# Add delay to prevent hitting GitHub rate limit
137160
sleep 5
138161
done < <(echo "${repos_json}" | jq -r --arg filter "${REPO_NAME_FILTER}" 'sort_by(.name) | .[] | select(.name | startswith($filter)) | .name')

tests/mock_curl_permissions.sh

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/bin/sh
2+
# =============================================================================
3+
# mock_curl_permissions.sh
4+
#
5+
# Purpose-built curl mock for github-add-repo-permissions tests. Unlike the
6+
# universal mock_curl.sh, this variant returns endpoint-specific responses so
7+
# the full repo-processing flow can be exercised:
8+
#
9+
# HEAD request (-I) page-count probe -> single page (no next header)
10+
# GET .../user (-o target) token validation -> HTTP 200
11+
# PUT team permissions -> HTTP 204
12+
# GET .../repos repo list -> JSON array from MOCK_REPOS_JSON
13+
#
14+
# MOCK_REPOS_JSON overrides the repo list body (default: two repos).
15+
# =============================================================================
16+
17+
is_put=0
18+
is_head=0
19+
ofile=""
20+
prev=""
21+
for arg in "$@"; do
22+
case "${arg}" in
23+
PUT) is_put=1 ;;
24+
-I) is_head=1 ;;
25+
esac
26+
[ "${prev}" = "-o" ] && ofile="${arg}"
27+
prev="${arg}"
28+
done
29+
30+
if [ "${is_put}" = 1 ]; then
31+
printf '204'
32+
exit 0
33+
fi
34+
35+
if [ "${is_head}" = 1 ]; then
36+
printf 'HTTP/1.1 200\r\n'
37+
exit 0
38+
fi
39+
40+
if [ -n "${ofile}" ]; then
41+
: > "${ofile}"
42+
printf '200'
43+
exit 0
44+
fi
45+
46+
DEFAULT_REPOS='[{"name":"repo-keep"},{"name":"repo-skip"}]'
47+
printf '%s' "${MOCK_REPOS_JSON:-${DEFAULT_REPOS}}"

tests/test_script_validation.bats

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,35 @@ _run_script() {
8686
}
8787

8888
@test "github-add-repo-permissions: exits 1 when ORG is not set" {
89-
_run_script "${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh" "export GITHUB_TOKEN=fake; unset ORG;"
89+
_run_script "${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh" "export GITHUB_TOKEN=fake; export REPO_PULL=read-team; unset ORG;"
9090
[ "$status" -eq 1 ]
9191
}
9292

93+
@test "github-add-repo-permissions: exits 1 when no permission level is set" {
94+
_run_script "${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh" "export GITHUB_TOKEN=fake; export ORG=test;"
95+
[ "$status" -eq 1 ]
96+
}
97+
98+
@test "github-add-repo-permissions: skips repo listed in REPO_PULL_EXCLUDE for pull only" {
99+
cp "${BATS_TEST_DIRNAME}/mock_curl_permissions.sh" "$MOCK_BIN/curl"
100+
chmod +x "$MOCK_BIN/curl"
101+
_run_script "${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh" \
102+
"export GITHUB_TOKEN=fake; export ORG=test; export REPO_PULL=read-team; export REPO_PULL_EXCLUDE=repo-skip;"
103+
[ "$status" -eq 0 ]
104+
[[ "$output" == *"Skipping pull on repo-skip (excluded)"* ]]
105+
[[ "$output" == *"Applied pull to read-team on repo-keep"* ]]
106+
}
107+
108+
@test "github-add-repo-permissions: applies to all repos when exclude list is empty" {
109+
cp "${BATS_TEST_DIRNAME}/mock_curl_permissions.sh" "$MOCK_BIN/curl"
110+
chmod +x "$MOCK_BIN/curl"
111+
_run_script "${REPO_ROOT}/org-admin/github-add-repo-permissions/github-add-repo-permissions.sh" \
112+
"export GITHUB_TOKEN=fake; export ORG=test; export REPO_PULL=read-team;"
113+
[ "$status" -eq 0 ]
114+
[[ "$output" == *"Applied pull to read-team on repo-keep"* ]]
115+
[[ "$output" == *"Applied pull to read-team on repo-skip"* ]]
116+
}
117+
93118
# ═══════════════════════════════════════════════════════════════════════════════
94119
# org-admin/github-archive-old-repos
95120
# ═══════════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)