Skip to content

Commit 6b16adf

Browse files
TMHSDigitalclaude
andcommitted
ci: least-privilege permissions and env-only inputs across workflows
Every workflow now sets top-level permissions: {} and grants scopes per job, matching .github/workflows/README.md (only sync.yml and drift-check did before). label-sync passes the PR number through env, creates labels only when missing instead of recoloring them with --force, drops the unneeded checkout, and skips fork PRs whose token cannot label. release-drafter is pinned to a commit SHA (v7.10.0) with job-level permissions. The drift-check and release-doc-sync composite actions route inputs and step outputs through env instead of expanding expressions into shell source. The sticky-issue step now hands its issues token to the CLI explicitly (new --issues-token / DRIFT_CHECK_ISSUES_TOKEN), so the clone PAT and the workflow GITHUB_TOKEN each do their own job. tests/test_workflow_hygiene.py and an extra composite-action test enforce both rules going forward. Closes #117 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
1 parent 7e4d109 commit 6b16adf

17 files changed

Lines changed: 225 additions & 82 deletions

‎.github/actions/drift-check/action.yml‎

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
#
55
# Reusable action invoked by:
66
# - the meta-repo's own drift-check.yml workflow (mode: all)
7-
# - tool-repo validate.yml workflows (mode: self) — wired up in Session D
7+
# - tool-repo validate.yml workflows (mode: self) - wired up in Session D
88
#
99
# What it does:
1010
# 1. Checks out THIS action's repo (TMHSDigital/Developer-Tools-Directory),
@@ -87,14 +87,14 @@ runs:
8787
# a known path. Use a dedicated subdirectory so we don't clobber the
8888
# caller's own checkout (which lives at GITHUB_WORKSPACE).
8989
- name: Checkout drift checker
90-
uses: actions/checkout@v5
90+
uses: actions/checkout@v7
9191
with:
9292
repository: TMHSDigital/Developer-Tools-Directory
9393
ref: ${{ inputs.meta-repo-ref }}
9494
path: .drift-checker
9595

9696
- name: Set up Python
97-
uses: actions/setup-python@v5
97+
uses: actions/setup-python@v6
9898
with:
9999
python-version: ${{ inputs.python-version }}
100100

@@ -105,7 +105,7 @@ runs:
105105
if [ -f requirements.txt ]; then
106106
pip install -r requirements.txt
107107
else
108-
echo "no requirements.txt — drift_check is stdlib-only, skipping"
108+
echo "no requirements.txt - drift_check is stdlib-only, skipping"
109109
fi
110110
111111
- name: Resolve meta-commit
@@ -120,17 +120,23 @@ runs:
120120
id: run
121121
shell: bash
122122
working-directory: .drift-checker
123+
# Inputs reach the shell only through env, never through expression
124+
# expansion inside run:, per .github/workflows/README.md.
123125
env:
124126
DRIFT_CHECK_TOKEN: ${{ inputs.github-token }}
127+
INPUT_MODE: ${{ inputs.mode }}
128+
INPUT_CALLER_PATH: ${{ inputs.caller-path }}
129+
INPUT_FORMAT: ${{ inputs.format }}
130+
META_SHA: ${{ steps.meta.outputs.sha }}
125131
run: |
126132
set +e
127133
EXTRA_ARGS=()
128-
if [ "${{ inputs.mode }}" = "all" ]; then
134+
if [ "$INPUT_MODE" = "all" ]; then
129135
EXTRA_ARGS+=(--all)
130-
elif [ "${{ inputs.mode }}" = "self" ]; then
131-
EXTRA_ARGS+=(--local "$GITHUB_WORKSPACE/${{ inputs.caller-path }}")
136+
elif [ "$INPUT_MODE" = "self" ]; then
137+
EXTRA_ARGS+=(--local "$GITHUB_WORKSPACE/$INPUT_CALLER_PATH")
132138
else
133-
echo "::error::invalid mode: ${{ inputs.mode }} (expected 'self' or 'all')"
139+
echo "::error::invalid mode: $INPUT_MODE (expected 'self' or 'all')"
134140
exit 2
135141
fi
136142
@@ -140,7 +146,7 @@ runs:
140146
"${EXTRA_ARGS[@]}" \
141147
--format json \
142148
--output /tmp/drift-findings.json \
143-
--meta-commit "${{ steps.meta.outputs.sha }}"
149+
--meta-commit "$META_SHA"
144150
FINDINGS_RC=$?
145151
146152
# Extract counts (default to 0 if jq missing or file absent).
@@ -155,15 +161,15 @@ runs:
155161
156162
# Now render the user-requested format (which writes to step summary
157163
# automatically when format=gh-summary).
158-
if [ "${{ inputs.format }}" != "json" ]; then
164+
if [ "$INPUT_FORMAT" != "json" ]; then
159165
python scripts/drift_check/cli.py \
160166
"${EXTRA_ARGS[@]}" \
161-
--format "${{ inputs.format }}" \
162-
--meta-commit "${{ steps.meta.outputs.sha }}"
167+
--format "$INPUT_FORMAT" \
168+
--meta-commit "$META_SHA"
163169
fi
164170
165171
# Tool errors (rc=2) abort immediately and skip the sticky-issue
166-
# step — there's nothing meaningful to publish if the checker
172+
# step - there's nothing meaningful to publish if the checker
167173
# itself failed. Drift findings (rc=1) are NOT propagated here so
168174
# the optional sticky-issue step still runs; the final
169175
# "Propagate checker exit code" step at the end of this composite
@@ -185,27 +191,32 @@ runs:
185191
# permissions block is the reliable source. issues-token defaults
186192
# to github-token for backward compatibility.
187193
DRIFT_CHECK_TOKEN: ${{ inputs.github-token }}
194+
# The CLI writes the sticky issue with DRIFT_CHECK_ISSUES_TOKEN and
195+
# clones with DRIFT_CHECK_TOKEN, so each token does one job.
196+
DRIFT_CHECK_ISSUES_TOKEN: ${{ inputs.issues-token != '' && inputs.issues-token || inputs.github-token }}
188197
GH_TOKEN: ${{ inputs.issues-token != '' && inputs.issues-token || inputs.github-token }}
198+
INPUT_MODE: ${{ inputs.mode }}
199+
INPUT_CALLER_PATH: ${{ inputs.caller-path }}
200+
META_SHA: ${{ steps.meta.outputs.sha }}
189201
run: |
190202
set +e
191-
ISSUES_TOKEN="${{ inputs.issues-token != '' && inputs.issues-token || inputs.github-token }}"
192-
if [ -z "$ISSUES_TOKEN" ]; then
203+
if [ -z "$DRIFT_CHECK_ISSUES_TOKEN" ]; then
193204
echo "::warning::update-sticky-issue=true but no issues-token / github-token provided; skipping"
194205
echo "action=skipped" >> "$GITHUB_OUTPUT"
195206
exit 0
196207
fi
197208
EXTRA_ARGS=()
198-
if [ "${{ inputs.mode }}" = "all" ]; then
209+
if [ "$INPUT_MODE" = "all" ]; then
199210
EXTRA_ARGS+=(--all)
200211
else
201-
EXTRA_ARGS+=(--local "$GITHUB_WORKSPACE/${{ inputs.caller-path }}")
212+
EXTRA_ARGS+=(--local "$GITHUB_WORKSPACE/$INPUT_CALLER_PATH")
202213
fi
203214
OUT=$(python scripts/drift_check/cli.py \
204215
"${EXTRA_ARGS[@]}" \
205216
--format json \
206217
--output /dev/null \
207218
--update-sticky-issue \
208-
--meta-commit "${{ steps.meta.outputs.sha }}" 2>&1)
219+
--meta-commit "$META_SHA" 2>&1)
209220
RC=$?
210221
echo "$OUT"
211222
# Parse the "Sticky issue: <action>" line emitted by cli.py.
@@ -228,16 +239,17 @@ runs:
228239
# don't mask the earlier failure that already failed the action.
229240
if: always()
230241
shell: bash
242+
env:
243+
RC: ${{ steps.run.outputs.exit-code }}
231244
run: |
232-
RC="${{ steps.run.outputs.exit-code }}"
233245
if [ -z "$RC" ]; then
234246
echo "drift checker did not run; nothing to propagate"
235247
exit 0
236248
fi
237249
echo "drift checker exit code: $RC"
238250
case "$RC" in
239251
0) echo "no drift detected" ;;
240-
1) echo "::error::drift detected — failing job per composite-action contract" ;;
252+
1) echo "::error::drift detected - failing job per composite-action contract" ;;
241253
2) echo "::error::tool error in drift checker" ;;
242254
*) echo "::warning::unexpected exit code from drift checker: $RC" ;;
243255
esac

‎.github/actions/release-doc-sync/action.yml‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ runs:
9797
# The sync script lives in this repo. Whether the caller is the meta-repo
9898
# itself or a tool repo, we always need the script at a known path.
9999
#
100-
# actions/checkout@v5 enforces that `path:` resolves UNDER GITHUB_WORKSPACE
100+
# actions/checkout@v7 enforces that `path:` resolves UNDER GITHUB_WORKSPACE
101101
# (it rejects absolute or out-of-tree paths with "Repository path '...' is
102102
# not under '...'"). So we check out to a dotted subdirectory and rely on
103103
# the explicit "Clean up workspace checkout" step below to remove it
@@ -111,14 +111,14 @@ runs:
111111
# and v1.8.2 introduced the cleanup-step pattern that actually works.
112112
# Regression-tested in tests/test_release_doc_sync.py.
113113
- name: Checkout release-doc-sync script
114-
uses: actions/checkout@v5
114+
uses: actions/checkout@v7
115115
with:
116116
repository: TMHSDigital/Developer-Tools-Directory
117117
ref: ${{ inputs.meta-repo-ref }}
118118
path: .release-doc-sync
119119

120120
- name: Set up Python
121-
uses: actions/setup-python@v5
121+
uses: actions/setup-python@v6
122122
with:
123123
python-version: ${{ inputs.python-version }}
124124

@@ -166,4 +166,4 @@ runs:
166166
if: always()
167167
shell: bash
168168
run: |
169-
rm -rf "${{ github.workspace }}/.release-doc-sync"
169+
rm -rf "$GITHUB_WORKSPACE/.release-doc-sync"

‎.github/workflows/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,5 +115,6 @@ When a third-party action releases a new version:
115115

116116
## Known follow-ups
117117

118-
- `release-drafter/release-drafter@v6` in `release-drafter.yml` is still pinned by major tag. It runs under `pull_request_target` but does not check out PR code, so the fork-RCE vector does not apply. SHA-pinning tracked for v1.7.
118+
- `release-drafter/release-drafter` in `release-drafter.yml` is SHA-pinned (v7.10.0). It runs under `pull_request_target` but never checks out PR code. Dependabot proposes SHA bumps.
119+
- Both conventions above are enforced by `tests/test_workflow_hygiene.py` (workflows) and `tests/test_composite_action_shape.py` (composite actions).
119120
- `peter-evans/create-pull-request` in `sync.yml` is SHA-pinned. Update as releases land.

‎.github/workflows/codeql.yml‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ on:
88
schedule:
99
- cron: "0 6 * * 1"
1010

11-
permissions:
12-
actions: read
13-
contents: read
14-
security-events: write
11+
permissions: {}
1512

1613
jobs:
1714
analyze:
15+
permissions:
16+
actions: read
17+
contents: read
18+
security-events: write
1819
name: Analyze Python
1920
runs-on: ubuntu-latest
2021
strategy:

‎.github/workflows/dependency-review.yml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ on:
44
pull_request:
55
branches: [main]
66

7-
permissions:
8-
contents: read
9-
pull-requests: write
7+
permissions: {}
108

119
jobs:
1210
dependency-review:
11+
permissions:
12+
contents: read
13+
pull-requests: write
1314
name: Audit dependencies
1415
runs-on: ubuntu-latest
1516
steps:

‎.github/workflows/drift-check.yml‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ on:
3333
- .github/actions/drift-check/**
3434
- .github/workflows/drift-check.yml
3535

36+
permissions: {}
37+
3638
concurrency:
3739
# Coalesce queued runs of the same trigger; do NOT cancel an in-flight
3840
# check (the sticky-issue upsert is non-idempotent during the gh edit
@@ -57,8 +59,8 @@ jobs:
5759
with:
5860
mode: all
5961
format: gh-summary
60-
# DRIFT_CHECK_TOKEN provides cross-repo Contents:Read on the 9
61-
# tool repos. GITHUB_TOKEN is the fallback for self-only runs
62+
# DRIFT_CHECK_TOKEN provides cross-repo Contents:Read on every
63+
# live tool repo in registry.json. GITHUB_TOKEN is the fallback for self-only runs
6264
# where remote sparse-checkout is not needed.
6365
github-token: ${{ secrets.DRIFT_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
6466
# GITHUB_TOKEN with the workflow's issues:write permission is
@@ -74,8 +76,13 @@ jobs:
7476
- name: Report outcome
7577
if: always()
7678
shell: bash
79+
env:
80+
EXIT_CODE: ${{ steps.drift.outputs.exit-code }}
81+
ERRORS: ${{ steps.drift.outputs.error-count }}
82+
WARNINGS: ${{ steps.drift.outputs.warning-count }}
83+
STICKY: ${{ steps.drift.outputs.sticky-issue-action }}
7784
run: |
78-
echo "exit-code: ${{ steps.drift.outputs.exit-code }}"
79-
echo "errors: ${{ steps.drift.outputs.error-count }}"
80-
echo "warnings: ${{ steps.drift.outputs.warning-count }}"
81-
echo "sticky: ${{ steps.drift.outputs.sticky-issue-action }}"
85+
echo "exit-code: $EXIT_CODE"
86+
echo "errors: $ERRORS"
87+
echo "warnings: $WARNINGS"
88+
echo "sticky: $STICKY"

‎.github/workflows/label-sync.yml‎

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,25 @@ on:
44
pull_request:
55
types: [opened, synchronize]
66

7-
permissions:
8-
contents: read
9-
pull-requests: write
7+
permissions: {}
108

119
jobs:
1210
label:
1311
name: Auto-label by path
1412
runs-on: ubuntu-latest
13+
# Fork PRs get a read-only token, so labeling cannot succeed there.
14+
if: github.event.pull_request.head.repo.full_name == github.repository
15+
permissions:
16+
contents: read
17+
pull-requests: write
1518
steps:
16-
- uses: actions/checkout@v7
17-
18-
- name: Get changed files
19-
id: changed
20-
run: |
21-
FILES=$(gh pr diff ${{ github.event.pull_request.number }} --name-only)
22-
echo "files<<EOF" >> "$GITHUB_OUTPUT"
23-
echo "$FILES" >> "$GITHUB_OUTPUT"
24-
echo "EOF" >> "$GITHUB_OUTPUT"
19+
- name: Apply labels by changed path
2520
env:
2621
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27-
28-
- name: Apply labels
22+
GH_REPO: ${{ github.repository }}
23+
PR_NUMBER: ${{ github.event.pull_request.number }}
2924
run: |
25+
FILES="$(gh pr diff "$PR_NUMBER" --name-only)"
3026
LABELS=""
3127
3228
if echo "$FILES" | grep -q "^standards/"; then
@@ -49,15 +45,17 @@ jobs:
4945
LABELS="$LABELS ci"
5046
fi
5147
52-
if [ -n "$LABELS" ]; then
53-
for label in $LABELS; do
54-
gh label create "$label" --force --color "ededed" 2>/dev/null || true
55-
gh pr edit ${{ github.event.pull_request.number }} --add-label "$label"
56-
done
57-
echo "Applied labels:$LABELS"
58-
else
48+
if [ -z "$LABELS" ]; then
5949
echo "No labels to apply"
50+
exit 0
6051
fi
61-
env:
62-
FILES: ${{ steps.changed.outputs.files }}
63-
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
52+
53+
EXISTING="$(gh label list --limit 200 --json name --jq '.[].name')"
54+
for label in $LABELS; do
55+
# Create only when missing, so existing label colors are kept.
56+
if ! printf '%s\n' "$EXISTING" | grep -qx "$label"; then
57+
gh label create "$label" --color "ededed" || true
58+
fi
59+
gh pr edit "$PR_NUMBER" --add-label "$label"
60+
done
61+
echo "Applied labels:$LABELS"

‎.github/workflows/pages.yml‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,17 @@ on:
99
- "registry.json"
1010
workflow_dispatch:
1111

12-
permissions:
13-
pages: write
14-
id-token: write
12+
permissions: {}
1513

1614
concurrency:
1715
group: pages
1816
cancel-in-progress: true
1917

2018
jobs:
2119
deploy:
20+
permissions:
21+
pages: write
22+
id-token: write
2223
environment:
2324
name: github-pages
2425
url: ${{ steps.deployment.outputs.page_url }}

‎.github/workflows/release-drafter.yml‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@ on:
66
pull_request_target:
77
types: [opened, reopened, synchronize]
88

9-
permissions:
10-
contents: write
11-
pull-requests: write
9+
permissions: {}
1210

1311
jobs:
1412
update-release-draft:
1513
name: Draft release notes
1614
runs-on: ubuntu-latest
15+
permissions:
16+
contents: write
17+
pull-requests: write
1718
steps:
18-
- uses: release-drafter/release-drafter@v7
19+
# Third-party action under pull_request_target: pinned to a commit SHA
20+
# (see .github/workflows/README.md). It never checks out PR code.
21+
- uses: release-drafter/release-drafter@978bb0b30c5ee8cdd332ced253758157150d43f0 # v7.10.0
1922
env:
2023
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

‎.github/workflows/release.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ on:
55
branches: [main]
66
workflow_dispatch:
77

8-
permissions:
9-
contents: read
8+
permissions: {}
109

1110
concurrency:
1211
group: release-${{ github.workflow }}
1312
cancel-in-progress: false
1413

1514
jobs:
1615
check-version:
16+
permissions:
17+
contents: read
1718
name: Check VERSION vs latest tag
1819
runs-on: ubuntu-latest
1920
if: "!contains(github.event.head_commit.message, '[skip ci]')"

0 commit comments

Comments
 (0)