-
Notifications
You must be signed in to change notification settings - Fork 376
392 lines (364 loc) · 16.8 KB
/
Copy pathdocs-build.yml
File metadata and controls
392 lines (364 loc) · 16.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
name: Docs - Build and commit
# Reusable workflow invoked by the review-driven, push-driven, and manual
# dispatch docs workflows. Concurrency is owned by the callers (group keyed
# on PR number) since workflow_call ignores a called workflow's concurrency
# block.
#
# Inputs are optional: the push-driven caller has a PR payload and leaves
# them unset, in which case the workflow reads from
# `github.event.pull_request`. The manual-dispatch and approval-driven paths
# have no PR payload, so they look the PR up via the API and pass the values
# explicitly.
on:
workflow_call:
inputs:
pr_number:
description: PR number (defaults to github.event.pull_request.number)
required: false
type: number
head_sha:
required: false
type: string
head_ref:
required: false
type: string
head_repo:
required: false
type: string
base_sha:
required: false
type: string
bypass_skip_docs:
description: When true, run even if the PR has skip-docs applied
required: false
type: boolean
default: false
bypass_attack_files:
description: When true, run even if the PR diff touches build-config attack files
required: false
type: boolean
default: false
secrets:
OMRS_PR_BOT_CLIENT_ID:
required: true
OMRS_PR_BOT_PRIVATE_KEY:
required: true
permissions: {}
jobs:
preflight:
runs-on: ubuntu-latest
permissions:
contents: read
issues: read
pull-requests: write
env:
REPO: ${{ github.repository }}
PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number }}
HEAD_REPO: ${{ inputs.head_repo || github.event.pull_request.head.repo.full_name }}
HEAD_REF: ${{ inputs.head_ref || github.event.pull_request.head.ref }}
HEAD_SHA: ${{ inputs.head_sha || github.event.pull_request.head.sha }}
BASE_SHA: ${{ inputs.base_sha || github.event.pull_request.base.sha }}
BYPASS_SKIP_DOCS: ${{ inputs.bypass_skip_docs }}
BYPASS_ATTACK_FILES: ${{ inputs.bypass_attack_files }}
outputs:
proceed: ${{ steps.gate.outputs.proceed }}
steps:
- name: Gate
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# HEAD_REF is interpolated into a refspec on the push line in the
# commit job; reject anything outside a conservative subset before
# any privileged step touches it.
if ! [[ "${HEAD_REF}" =~ ^[A-Za-z0-9._/-]+$ ]]; then
echo "Refusing unsafe head ref: ${HEAD_REF}"
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
labels=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/labels" --jq '[.[].name] | join(",")')
if echo ",${labels}," | grep -q ",skip-docs,"; then
if [ "${BYPASS_SKIP_DOCS}" = "true" ]; then
echo "skip-docs label present but bypass requested; proceeding"
else
echo "skip-docs label set; aborting"
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
is_fork=false
if [[ "${HEAD_REPO}" != "${REPO}" ]]; then
is_fork=true
fi
# Each caller implements its own approval gate; re-verify here so a
# future caller that forgets to gate (or a regression in caller
# logic) can't smuggle PR code execution through this workflow.
approved_by_writer=false
for u in $(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/reviews?per_page=100" --jq '.[] | select(.state == "APPROVED") | .user.login' | sort -u); do
permission=$(gh api "repos/${REPO}/collaborators/${u}/permission" --jq '.permission' 2>/dev/null || echo "none")
if [[ "${permission}" == "write" || "${permission}" == "admin" ]]; then
approved_by_writer=true
break
fi
done
if [[ "${approved_by_writer}" != "true" ]]; then
echo "No write-access approval present; aborting"
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Anything that influences what `yarn turbo run document` actually
# executes belongs here; modifying these files in a PR head could
# otherwise hijack the build job's typedoc plugin code path.
ATTACK_FILES=(
"packages/framework/esm-framework/typedoc.json"
"packages/framework/esm-framework/tsconfig.typedoc.json"
"tools/document.sh"
"turbo.json"
".yarnrc.yml"
)
# Resolve the diff at the SHAs this run pinned, not via pulls/N/files
# (which returns the *current* PR diff): a contributor could
# otherwise revert attack-listed files after this gate runs but
# before the build job checks that SHA out.
#
# API call here so we correctly fail if BASE_SHA or HEAD_SHA do not resolve
compare=$(gh api "repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}")
# compare caps `files` at 300 and `--paginate` walks commits, not
# files, so at the cap a complete diff is indistinguishable from a
# truncated one. Refuse rather than scan a partial list.
file_count=$(echo "${compare}" | jq '.files | length')
if [ "${file_count}" -ge 300 ] && [ "${BYPASS_ATTACK_FILES}" != "true" ]; then
echo "PR diff has ${file_count} files, at or above the compare API's cap; cannot audit it for docs-build configuration changes"
gh pr comment "${PR_NUMBER}" --repo "${REPO}" --body "Automatic documentation regeneration is disabled for this PR because its diff (${file_count} files) is too large for the docs-build configuration audit to be complete. After auditing the changes, a maintainer can either:
- Apply the \`skip-docs\` label to skip docs entirely, or
- Trigger the **Docs - Rebuild on demand** workflow from the Actions tab with **bypass_attack_files** enabled to force the build."
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# No `?` after .files: a response shape we don't expect should be a
# hard error here, not an empty list that opens the gate.
changed=$(echo "${compare}" | jq -r '.files[].filename')
touched_attack=false
attack_match=""
while IFS= read -r f; do
[ -z "$f" ] && continue
for a in "${ATTACK_FILES[@]}"; do
if [[ "$f" == "$a" ]]; then
touched_attack=true
attack_match="$f"
break 2
fi
done
case "$f" in
packages/tooling/typedoc-plugin-file-categories/*) touched_attack=true; attack_match="$f"; break ;;
packages/framework/*/tsconfig.json|packages/framework/*/tsconfig.build.json) touched_attack=true; attack_match="$f"; break ;;
.yarn/plugins/*|.yarn/releases/*|.yarn/versions/*) touched_attack=true; attack_match="$f"; break ;;
esac
# Callers run on pull_request_target or workflow_run, both of
# which use the base-branch workflow file (which transitively
# pulls this one via uses:), so a fork's modifications to docs
# workflow files never execute. Block here so the change still
# requires maintainer audit via skip-docs rather than passing
# auto-build silently.
if [[ "${is_fork}" == "true" ]]; then
case "$f" in
.github/workflows/docs-*.yml) touched_attack=true; attack_match="$f"; break ;;
esac
fi
done <<< "${changed}"
if [[ "${touched_attack}" == "true" ]]; then
if [[ "${BYPASS_ATTACK_FILES}" == "true" ]]; then
echo "Attack file touched (${attack_match}) but bypass requested; proceeding"
else
echo "PR modifies docs-build configuration (${attack_match}); refusing auto-build"
gh pr comment "${PR_NUMBER}" --repo "${REPO}" --body "Automatic documentation regeneration is disabled for this PR because it touches \`${attack_match}\`, which is part of the docs build configuration. After auditing the changes, a maintainer can either:
- Apply the \`skip-docs\` label to skip docs entirely, or
- Trigger the **Docs - Rebuild on demand** workflow from the Actions tab with **bypass_attack_files** enabled to force the build."
echo "proceed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
echo "proceed=true" >> "$GITHUB_OUTPUT"
build:
needs: preflight
if: needs.preflight.outputs.proceed == 'true'
runs-on: ubuntu-latest
# contents:read is the minimum actions/checkout needs to authenticate;
# everything else stays denied. This is the only job that executes PR
# code (typedoc plugins, build scripts), and `persist-credentials: false`
# below keeps the token from reaching the working tree.
permissions:
contents: read
env:
# Block lifecycle scripts on PR-controlled deps so `yarn install`
# can't side-execute arbitrary code from a malicious package.json.
YARN_ENABLE_SCRIPTS: "0"
steps:
- name: Checkout PR head (sandboxed)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.head_sha || github.event.pull_request.head.sha }}
repository: ${{ inputs.head_repo || github.event.pull_request.head.repo.full_name }}
# Belt-and-suspenders: this job has no tokens to begin with, but
# if a future change grants one, don't leave it in .git/config
# where PR-controlled paths could exfiltrate it.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
- name: Install dependencies
run: yarn install --immutable
- name: Build documentation
run: yarn turbo run document
- name: Validate output
# typedoc plugins run as PR-controlled code and could write symlinks
# into the docs tree — they'd then be carried by the artifact and
# extracted in the privileged commit job. Reject them at the source.
run: |
set -euo pipefail
docs_dir="packages/framework/esm-framework/docs"
if [ ! -d "$docs_dir" ]; then
echo "Expected docs dir not found"
exit 1
fi
if find "$docs_dir" -type l | grep -q .; then
echo "Symlinks found in docs output"
exit 1
fi
- name: Upload docs artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docs
path: packages/framework/esm-framework/docs/
retention-days: 1
include-hidden-files: false
commit:
needs: [preflight, build]
# Explicit preflight gate (in addition to `needs:`) so this privileged
# job can never run if preflight didn't pass, even if `build`'s `if:`
# is changed in a future refactor.
if: needs.preflight.outputs.proceed == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
env:
REPO: ${{ github.repository }}
PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number }}
HEAD_REF: ${{ inputs.head_ref || github.event.pull_request.head.ref }}
HEAD_REPO: ${{ inputs.head_repo || github.event.pull_request.head.repo.full_name }}
outputs:
pushed_sha: ${{ steps.commit.outputs.pushed_sha }}
steps:
- name: Checkout the SHA the build ran against
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.head_sha || github.event.pull_request.head.sha }}
repository: ${{ inputs.head_repo || github.event.pull_request.head.repo.full_name }}
persist-credentials: false
- name: Download docs artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: docs
path: /tmp/docs-artifact
- name: Validate artifact contents
run: |
set -euo pipefail
# Artifact tarball is produced by PR-controlled typedoc plugins.
# Reject anything that could escape the docs tree on extraction.
if find /tmp/docs-artifact -type l | grep -q .; then
echo "Symlinks present in artifact"
exit 1
fi
if find /tmp/docs-artifact -mindepth 1 ! -type f ! -type d | grep -q .; then
echo "Non-regular files present in artifact"
exit 1
fi
while IFS= read -r f; do
relative="${f#/tmp/docs-artifact/}"
case "$relative" in
*/../*|../*|*/..) echo "Path traversal in artifact: $relative"; exit 1 ;;
esac
done < <(find /tmp/docs-artifact -type f)
- name: Replace docs tree
run: |
set -euo pipefail
docs_dir="packages/framework/esm-framework/docs"
rm -rf "$docs_dir"
mkdir -p "$docs_dir"
cp -a /tmp/docs-artifact/. "$docs_dir/"
- name: Commit and push
id: commit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "openmrs-bot"
git config user.email "infrastructure@openmrs.org"
git add packages/framework/esm-framework/docs/
if git diff --cached --quiet; then
echo "No doc changes; treating as success"
echo "pushed=true" >> "$GITHUB_OUTPUT"
echo "pushed_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
exit 0
fi
git commit -m "(chore) Add docs"
# Push from detached HEAD to the specific branch. Non-FF fails
# (head moved during build); never force-push. Token lives only
# on this command line, never persisted to .git/config, so the
# working tree never holds a write-scoped credential reachable
# from PR-controlled paths.
push_url="https://x-access-token:${GH_TOKEN}@github.com/${HEAD_REPO}.git"
if git push "${push_url}" "HEAD:refs/heads/${HEAD_REF}"; then
echo "pushed=true" >> "$GITHUB_OUTPUT"
# Pin to local HEAD (= the SHA we just pushed). A fresh
# `current head` API lookup would race against contributor
# pushes and could attest a SHA whose docs we didn't build.
echo "pushed_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
else
echo "pushed=false" >> "$GITHUB_OUTPUT"
exit 1
fi
- name: Comment on push failure
if: failure() && steps.commit.outputs.pushed == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment "${PR_NUMBER}" --repo "${REPO}" --body "Documentation was regenerated successfully but pushing the docs commit to this branch failed. Common causes:
- The branch advanced after the build started — push another commit to retrigger.
- The PR is from a fork without **Allow edits by maintainers** enabled (please enable it).
If neither applies, apply the \`skip-docs\` label and push the docs commit yourself."
publish-state:
needs: [preflight, commit]
# Skip when commit didn't actually publish (e.g., non-FF push fail).
# Posting a check run for a SHA we didn't push would falsely claim
# docs are current for that SHA.
if: needs.preflight.outputs.proceed == 'true' && needs.commit.outputs.pushed_sha != ''
runs-on: ubuntu-latest
# API-only — no checkout, no PR working tree on disk. Keeps the App
# token isolated from anything PR-controlled.
permissions: {}
env:
REPO: ${{ github.repository }}
PUSHED_SHA: ${{ needs.commit.outputs.pushed_sha }}
steps:
- name: Mint app token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ secrets.OMRS_PR_BOT_CLIENT_ID }}
private-key: ${{ secrets.OMRS_PR_BOT_PRIVATE_KEY }}
- name: Post check run
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
gh api -X POST "repos/${REPO}/check-runs" \
-f name='Docs / Regenerated' \
-f head_sha="${PUSHED_SHA}" \
-f status=completed \
-f conclusion=success \
-f 'output[title]=Documentation regenerated' \
-f "output[summary]=Generated docs reflect the PR head at \`${PUSHED_SHA}\`."