Repository navigation
625 lines (600 loc) · 35.6 KB
/
Copy pathpm-github-closeout.yml
File metadata and controls
625 lines (600 loc) · 35.6 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
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
# Reusable closeout: verified merged delivery → gated tracker diff → review PR.
# Scope helpers intentionally match gated sync; executable parity tests prevent drift.
name: Gated merged-PR → pm closeout
on:
workflow_call:
inputs:
repository:
description: "owner/repo whose merged PRs deliver items (defaults to the calling repository)"
type: string
required: false
default: ${{ github.repository }}
pm-github-version:
description: "Deprecated: fallback only for a non-release commit; pin a release SHA instead"
type: string
required: false
default: ""
npm-project-dir:
description: "Caller npm project directory for dependencies and post-closeout-script (relative, inside the workspace)"
type: string
required: false
default: "."
post-closeout-script:
description: "Caller npm script after closeout, before the final gate; missing skips, empty disables it"
type: string
required: false
default: "changelog:full"
ci-workflows:
description: "Caller workflow filenames with workflow_dispatch; empty disables dispatch and auto-merge"
type: string
required: false
default: "ci.yml codeql.yml"
required-checks:
description: "One required context name per line; spaces are preserved and blank lines ignored; used when protection metadata is unavailable"
type: string
required: false
default: ""
automation-app-client-id:
description: "Optional GitHub App client ID; pair with automation_app_private_key for eligible native PR checks"
type: string
required: false
default: ""
node-version:
description: "Node.js version for the pm CLI"
type: string
required: false
default: "22.18.0"
secrets:
automation_app_private_key:
description: "Private key for the optional automation App installed on the calling repository"
required: false
concurrency:
group: pm-github-closeout-${{ github.repository }}
cancel-in-progress: false
# No workflow-wide grant: the job below requests exactly what it needs.
permissions: {}
jobs:
gated-closeout:
# Read delivery evidence, push only the closeout branch, and maintain one PR.
permissions:
contents: write
pull-requests: write
checks: read
actions: write
runs-on: ubuntu-latest
env:
REPOSITORY: ${{ inputs.repository }}
SYNC_BRANCH: pm-github/closeout
REQUESTED_PM_GITHUB_VERSION: ${{ inputs.pm-github-version }}
NPM_PROJECT_DIR: ${{ inputs.npm-project-dir }}
POST_CLOSEOUT_SCRIPT: ${{ inputs.post-closeout-script }}
REQUIRED_CHECKS: ${{ inputs.required-checks }}
CI_WORKFLOWS: ${{ inputs.ci-workflows }}
CALLING_REPOSITORY: ${{ github.repository }}
steps:
- name: Require caller default branch
id: caller_branch
env:
RUN_REF: ${{ github.ref }}
EVENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
DEFAULT_BRANCH="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.default_branch // empty' 2>/dev/null)" || DEFAULT_BRANCH=""
DEFAULT_BRANCH="${DEFAULT_BRANCH:-${EVENT_DEFAULT_BRANCH:-}}"
if [[ -z "${DEFAULT_BRANCH}" ]] || ! git check-ref-format "refs/heads/${DEFAULT_BRANCH}" >/dev/null 2>&1; then
echo "::error::Cannot resolve a valid caller default branch from the repository API or event payload."
exit 1
fi
expected_ref="refs/heads/${DEFAULT_BRANCH}"
if [[ "${RUN_REF}" != "${expected_ref}" ]]; then
printf '::error::Closeout ref "%s" must match caller default branch ref "%s".\n' "${RUN_REF}" "${expected_ref}"
exit 1
fi
printf 'branch=%s\n' "${DEFAULT_BRANCH}" >> "${GITHUB_OUTPUT}"
printf 'DEFAULT_BRANCH=%s\n' "${DEFAULT_BRANCH}" >> "${GITHUB_ENV}"
- name: Resolve pm-github release version
shell: bash
env:
# The job context identifies the reusable workflow, not the caller.
WORKFLOW_SHA: ${{ job.workflow_sha }}
run: |
set -euo pipefail
[[ "${WORKFLOW_SHA}" =~ ^[a-f0-9]{40}$ ]] || { echo "::error::Cannot identify the reusable workflow commit from job.workflow_sha."; exit 1; }
# No checkout, authentication, API pagination, or local tags involved.
remote_tags="$(git ls-remote --tags https://github.com/unbraind/pm-github 'refs/tags/v*')" || { echo "::error::Cannot read pm-github release tags; refusing an unverified install."; exit 1; }
declare -A tag_commits=() peeled_commits=()
while read -r sha ref; do
[[ "${sha}" =~ ^[a-f0-9]{40}$ ]] || continue
# Validate the whole tag before using it as an associative-array key.
# declare evaluates subscripts, so arbitrary remote refs are not operands.
grep -zEq '^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?(\^\{\})?$' < <(printf '%s\0' "${ref}") || continue
if [[ "${ref}" == *'^{}' ]]; then
declare "peeled_commits[${ref%'^{}'}]=${sha}"
elif [[ "${ref}" == refs/tags/v* ]]; then
declare "tag_commits[${ref}]=${sha}"
fi
done <<< "${remote_tags}"
PM_GITHUB_VERSION=""
for ref in "${!tag_commits[@]}"; do
# Annotated tags must match their peeled commit, never the tag object.
[[ "${peeled_commits[$ref]-${tag_commits[$ref]}}" == "${WORKFLOW_SHA}" ]] || continue
candidate="${ref#refs/tags/v}"
grep -zEq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$' < <(printf '%s\0' "${candidate}") || continue
if [[ -n "${PM_GITHUB_VERSION}" && "${PM_GITHUB_VERSION}" != "${candidate}" ]]; then
echo "::error::Multiple release versions tag this workflow commit; refusing an ambiguous install."
exit 1
fi
PM_GITHUB_VERSION="${candidate}"
done
if [[ -z "${PM_GITHUB_VERSION}" ]]; then
if [[ -z "${REQUESTED_PM_GITHUB_VERSION}" ]]; then
echo "::error::No release tag matches this reusable workflow commit. Pin a release SHA (a commit tagged vN.N.N or vN.N.N-N)."
exit 1
fi
echo "::warning::pm-github-version is deprecated; no release tag matches this workflow commit, so using the exact legacy input. Pin a release SHA instead."
PM_GITHUB_VERSION="${REQUESTED_PM_GITHUB_VERSION}"
elif [[ -n "${REQUESTED_PM_GITHUB_VERSION}" && "${REQUESTED_PM_GITHUB_VERSION}" != "${PM_GITHUB_VERSION}" ]]; then
echo "::warning::pm-github-version is deprecated and differs from the workflow release tag; using the tag version."
fi
grep -zEq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$' < <(printf '%s\0' "${PM_GITHUB_VERSION}") || { echo "::error::pm-github-version must be an exact numeric version: N.N.N or N.N.N-N."; exit 1; }
printf 'PM_GITHUB_VERSION=%s\n' "${PM_GITHUB_VERSION}" >> "${GITHUB_ENV}"
- name: Validate closeout inputs
shell: bash
run: |
set -euo pipefail
[[ "${REPOSITORY}" == "${CALLING_REPOSITORY}" ]] || { echo "::error::repository must equal the calling repository (${CALLING_REPOSITORY})."; exit 1; }
grep -zEq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$' < <(printf '%s\0' "${PM_GITHUB_VERSION}") || { echo "::error::pm-github-version must be an exact numeric version: N.N.N or N.N.N-N."; exit 1; }
[[ "${SYNC_BRANCH}" == pm-github/closeout ]] || { echo "::error::Closeout branch must be pm-github/closeout."; exit 1; }
git check-ref-format "refs/heads/${SYNC_BRANCH}"
[[ "${SYNC_BRANCH}" != "${DEFAULT_BRANCH}" ]] || { echo "::error::The closeout bot branch cannot be the caller default branch."; exit 1; }
[[ -z "${POST_CLOSEOUT_SCRIPT:-}" || "${POST_CLOSEOUT_SCRIPT:-}" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.:-]*$ ]] || { echo "::error::post-closeout-script must be an npm script name, not a shell command."; exit 1; }
- name: Checkout caller default branch
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
fetch-tags: true
ref: ${{ steps.caller_branch.outputs.branch }}
persist-credentials: false
- name: Validate npm project directory
shell: bash
run: |
set -euo pipefail
invalid_npm_project() {
echo "::error::npm-project-dir must be a relative path without .. segments, resolve inside the workspace, and contain package.json."
exit 1
}
[[ -n "${NPM_PROJECT_DIR}" && "${NPM_PROJECT_DIR}" != /* && "${NPM_PROJECT_DIR}" != *[$'\r\n:']* ]] || invalid_npm_project
case "/${NPM_PROJECT_DIR}/" in */../*) invalid_npm_project ;; esac
workspace="$(realpath -e -- "${GITHUB_WORKSPACE}" 2>/dev/null)" || invalid_npm_project
IFS= read -r -d '' npm_project_path < <(realpath -ez -- "${GITHUB_WORKSPACE}/${NPM_PROJECT_DIR}" 2>/dev/null) || invalid_npm_project
[[ "${npm_project_path}" == "${workspace}" || "${npm_project_path}" == "${workspace}/"* ]] || invalid_npm_project
[[ "${npm_project_path}" != *[$'\r\n:']* && -f "${npm_project_path}/package.json" ]] || invalid_npm_project
printf 'NPM_PROJECT_PATH=%s\n' "${npm_project_path}" >> "${GITHUB_ENV}"
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
cache: npm
cache-dependency-path: |
${{ env.NPM_PROJECT_PATH }}/package-lock.json
${{ env.NPM_PROJECT_PATH }}/npm-shrinkwrap.json
# Rebuild the closeout branch from the current default branch: it only
# holds automated closeout commits, keeping the PR a clean review of
# exactly this run's proposed change instead of an ever-growing merge.
- name: Prepare sync branch from default branch
shell: bash
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
sync_lease="$(git rev-parse --verify "refs/remotes/origin/${SYNC_BRANCH}" 2>/dev/null || true)"
printf 'SYNC_LEASE=%s\n' "${sync_lease}" >> "${GITHUB_ENV}"
git switch --force-create "${SYNC_BRANCH}" "origin/${DEFAULT_BRANCH}"
# The calling repo's own devDependency provides the pm CLI (npm ci).
- name: Install dependencies
shell: bash
run: |
set -euo pipefail
(cd "${NPM_PROJECT_PATH}" && npm ci --ignore-scripts)
printf '%s/node_modules/.bin\n' "${NPM_PROJECT_PATH}" >> "${GITHUB_PATH}"
# The explicit publication allowlist is the SDK-resolved tracker (except
# extensions, locks, checkpoints and extension setup files) and CHANGELOG.md. Nothing else may
# change, even if unstaged or untracked. Keep all boundaries on this list.
- name: Resolve sync commit scope
shell: bash
run: |
set -euo pipefail
PM_SYNC_TRACKER="$(cd "${NPM_PROJECT_PATH}" && node --input-type=module -e 'import { resolvePmRoot } from "@unbrained/pm-cli/sdk"; import { relative } from "node:path"; const p = relative(process.env.GITHUB_WORKSPACE, resolvePmRoot(process.env.GITHUB_WORKSPACE)); if (!p || p === ".." || p.startsWith("../") || /[\r\n]/.test(p)) throw new Error("Sync tracker must be inside the repository"); process.stdout.write(p);')"
export PM_SYNC_TRACKER
printf 'PM_SYNC_TRACKER=%s\n' "${PM_SYNC_TRACKER}" >> "${GITHUB_ENV}"
cat > "${RUNNER_TEMP}/pm-github-sync-scope.sh" <<'SCOPE'
mapfile -t sync_scope < <(printf '%s\n' \
":(top,literal)${PM_SYNC_TRACKER}" \
':(top,literal)CHANGELOG.md' \
":(top,exclude,literal)${PM_SYNC_TRACKER}/extensions" \
":(top,exclude,literal)${PM_SYNC_TRACKER}/locks" \
":(top,exclude,literal)${PM_SYNC_TRACKER}/checkpoints" \
":(top,exclude,literal)${PM_SYNC_TRACKER}/settings.json" \
":(top,exclude,literal)${PM_SYNC_TRACKER}/history/_workspace.jsonl")
restore_sync_setup() {
for setup_path in "${PM_SYNC_TRACKER}/settings.json" "${PM_SYNC_TRACKER}/history/_workspace.jsonl"; do
if git cat-file -e "HEAD:${setup_path}" 2>/dev/null; then
git --literal-pathspecs restore --source=HEAD --staged --worktree -- "${setup_path}"
else
git --literal-pathspecs rm -q --cached --ignore-unmatch -- "${setup_path}"
rm -f -- "${setup_path}"
fi
done
}
check_sync_scope() {
local entry path failed=0
git status --porcelain=v1 -z --untracked-files=all > "${RUNNER_TEMP}/pm-github-sync-status"
while IFS= read -r -d '' entry; do
path="${entry:3}"
check_sync_path "${path}" || failed=1
# Porcelain -z renames/copies contain a second, original path.
if [[ "${entry:0:2}" == *R* || "${entry:0:2}" == *C* ]]; then
IFS= read -r -d '' path || { echo '::error::Incomplete Git status record.'; return 1; }
check_sync_path "${path}" || failed=1
fi
done < "${RUNNER_TEMP}/pm-github-sync-status"
return "${failed}"
}
check_sync_path() {
if [[ "$1" == "${PM_SYNC_TRACKER}/extensions" || "$1" == "${PM_SYNC_TRACKER}/extensions/"* || \
"$1" == "${PM_SYNC_TRACKER}/locks" || "$1" == "${PM_SYNC_TRACKER}/locks/"* || \
"$1" == "${PM_SYNC_TRACKER}/checkpoints" || "$1" == "${PM_SYNC_TRACKER}/checkpoints/"* ]]; then
: # Runtime paths are deliberately outside the publication allowlist.
elif [[ "$1" == "${PM_SYNC_TRACKER}/"* || "$1" == CHANGELOG.md ]]; then
return 0
fi
printf '::error::Changed path outside sync commit scope: %q\n' "$1"
return 1
}
gate_sync_change() {
check_sync_scope
git ls-files -z --modified --deleted --others --exclude-standard -- "${sync_scope[0]}" "${sync_scope[@]:2}" > "${RUNNER_TEMP}/pm-github-sync-stage-paths"
if [[ -s "${RUNNER_TEMP}/pm-github-sync-stage-paths" ]]; then
git --literal-pathspecs add --pathspec-from-file="${RUNNER_TEMP}/pm-github-sync-stage-paths" --pathspec-file-nul
fi
if [[ -e CHANGELOG.md ]] || git ls-files --error-unmatch -- CHANGELOG.md >/dev/null 2>&1; then
git --literal-pathspecs add -- CHANGELOG.md
fi
git diff --cached --name-only -z --no-renames -- "${sync_scope[@]}" > "${RUNNER_TEMP}/pm-github-sync-commit-paths"
mapfile -d '' -t sync_paths < "${RUNNER_TEMP}/pm-github-sync-commit-paths"
: > "${RUNNER_TEMP}/pm-github-sync.patch"
if [[ ${#sync_paths[@]} -gt 0 ]]; then
git --literal-pathspecs diff --cached --no-color --no-ext-diff --no-textconv --no-renames --binary --unified=2147483647 -- "${sync_paths[@]}" > "${RUNNER_TEMP}/pm-github-sync.patch"
fi
pm github gate --diff "${RUNNER_TEMP}/pm-github-sync.patch" --json
}
SCOPE
- name: Install pinned pm-github extension
shell: bash
run: |
set -euo pipefail
pm package install "npm:pm-github@${PM_GITHUB_VERSION}" --project
node -e 'if (require("./" + process.argv[2] + "/extensions/pm-github/package.json").version !== process.argv[1]) process.exit(1)' "${PM_GITHUB_VERSION}" "${PM_SYNC_TRACKER}"
- name: Configure PM merge drivers
run: pm merge install
- name: Verify gate is available
run: pm github gate --json
- name: Close delivered tracker items
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
# Full history catches up after missed schedules; the command skips terminal items.
# Keep the report private until the proposed change has passed the privacy gate.
required_args=()
# Forward the newline-joined list as one literal argument; TypeScript parses lines.
if [[ -n "${REQUIRED_CHECKS:-}" ]]; then
required_args+=(--required-checks "${REQUIRED_CHECKS}")
fi
if ! pm github closeout --repo "${REPOSITORY}" --since 1970-01-01 --apply --json "${required_args[@]}" > "${RUNNER_TEMP}/pm-github-closeout.json" 2> "${RUNNER_TEMP}/pm-github-closeout.err"; then
echo "::error::Closeout refused; check token read access to pull requests, checks and branch rules, and tracker close validation. No branch was published."
exit 1
fi
jq -e '.command == "github closeout" and .dry_run == false and .failed == 0 and (.closed | type == "number")' "${RUNNER_TEMP}/pm-github-closeout.json" >/dev/null
# Only a local tracker refusal makes missing head evidence actionable.
# Foreign, terminal, excluded and unlinked PRs cannot block this run.
jq -e 'all(.plan[]; (.code | type) == "string")' "${RUNNER_TEMP}/pm-github-closeout.json" >/dev/null
if jq -e 'any(.plan[]; .action == "skip" and (.code == "head_evidence_unavailable" or .code == "required_checks_unverified"))' "${RUNNER_TEMP}/pm-github-closeout.json" >/dev/null; then
echo "::error::Closeout refused; check token read access to head checks for linked local tracker items, and declare required-checks when protection metadata is unavailable. No branch was published."
exit 1
fi
# Merge facts are checked before head lookup and have a distinct decision.
if jq -e 'any(.plan[]; .action == "skip" and .code == "incomplete_merge")' "${RUNNER_TEMP}/pm-github-closeout.json" >/dev/null; then
echo "::error::Closeout refused; incomplete merged-PR metadata blocks a linked local tracker item. No branch was published."
exit 1
fi
printf 'CLOSEOUT_CLOSED=%s\n' "$(jq -r '.closed' "${RUNNER_TEMP}/pm-github-closeout.json")" >> "${GITHUB_ENV}"
- name: Run caller post-closeout script
shell: bash
run: |
set -euo pipefail
if [[ -n "${POST_CLOSEOUT_SCRIPT}" ]]; then
(cd "${NPM_PROJECT_PATH}" && npm run --if-present "${POST_CLOSEOUT_SCRIPT}")
fi
# Check every changed path immediately after the caller script. Extension
# setup files stay outside the patch and are restored before publication.
- name: Gate tracker and regenerated changelog
shell: bash
run: |
set -euo pipefail
source "${RUNNER_TEMP}/pm-github-sync-scope.sh"
gate_sync_change
- name: Verify strict pm health
run: pm health --strict-exit
# Mint only after caller scripts and local gates have finished. The key
# is passed only to the pinned official action; its token is repo-scoped.
- name: Create automation app token
id: automation_token
if: inputs.automation-app-client-id != ''
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
client-id: ${{ inputs.automation-app-client-id }}
private-key: ${{ secrets.automation_app_private_key }}
repositories: ${{ github.repository }}
skip-token-revoke: false
permission-contents: write
permission-pull-requests: write
permission-actions: write
permission-checks: read
permission-statuses: read
- name: Validate publication credentials
id: publication_auth
env:
APP_CLIENT_ID: ${{ inputs.automation-app-client-id }}
HAS_APP_PRIVATE_KEY: ${{ secrets.automation_app_private_key != '' }}
HAS_AUTOMATION_TOKEN: ${{ steps.automation_token.outputs.token != '' }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${APP_CLIENT_ID:-}" ]]; then
if [[ "${HAS_APP_PRIVATE_KEY:-false}" == true ]]; then
echo "::error::automation_app_private_key requires automation-app-client-id."
exit 1
fi
echo "::notice::Using GITHUB_TOKEN: native PR runs require approval, and dispatched checks cannot satisfy PR branch rules. Configure the optional automation App for unattended landing."
elif [[ "${HAS_APP_PRIVATE_KEY:-false}" != true || "${HAS_AUTOMATION_TOKEN:-false}" != true ]]; then
echo "::error::Configured automation App did not produce a token; check its private key, installation and repository permissions."
exit 1
else
# Start the App deadline immediately after minting, before publication.
printf 'deadline=%s\n' "$(( $(date +%s) + 2400 ))" >> "${GITHUB_OUTPUT}"
fi
- name: Commit gated closeout changes and open review PR
env:
GH_TOKEN: ${{ steps.automation_token.outputs.token || github.token }}
shell: bash
run: |
set -euo pipefail
trap 'echo "::error::Closeout publication refused; token requires contents:write and pull-requests:write, and repository settings must allow Actions to create PRs."' ERR
source "${RUNNER_TEMP}/pm-github-sync-scope.sh"
gate_sync_change
# gh pr list --head matches branch names, and a fork can open a PR
# from a branch with the same name, so filter the results to PRs whose
# head repository is this repository (never trust .[0] alone).
own_open_pr() {
gh pr list -R "${REPOSITORY}" --head "${SYNC_BRANCH}" --base "${DEFAULT_BRANCH}" --state open \
--json number,headRepository,headRepositoryOwner,isCrossRepository \
| jq -r --arg repository "${REPOSITORY}" \
'[ .[] | select(.isCrossRepository == false and (.headRepositoryOwner.login + "/" + .headRepository.name) == $repository) | .number ][0] // empty'
}
close_own_open_pr() {
local number
number="$(own_open_pr)"
if [[ -n "${number}" ]]; then
gh pr close "${number}" -R "${REPOSITORY}" --comment "No delivered tracker items remain to close; closing this review PR."
fi
}
if [[ "${CLOSEOUT_CLOSED}" == 0 ]] || git diff --cached --quiet -- "${sync_scope[0]}" "${sync_scope[@]:2}"; then
# Changelog drift alone belongs to the caller's changelog check;
# publish regenerated output only alongside tracker changes.
if ! git diff --cached --quiet -- CHANGELOG.md; then
if git cat-file -e HEAD:CHANGELOG.md 2>/dev/null; then
git --literal-pathspecs restore --source=HEAD --staged --worktree -- CHANGELOG.md
else
git --literal-pathspecs rm -q --cached --ignore-unmatch -- CHANGELOG.md
rm -f -- CHANGELOG.md
fi
echo "::notice::The caller's changelog differs from a fresh regeneration; the repository's own changelog:check owns that drift. Discarding CHANGELOG.md because no tracker item changed."
fi
restore_sync_setup
echo "No delivered tracker items closed; no branch or PR is needed."
# A stale own review PR would still look actionable although this
# run has no change to propose, so close it before returning.
close_own_open_pr
exit 0
fi
git diff --cached --check
links_file="${RUNNER_TEMP}/pm-github-sync-links.md"
: > "${links_file}"
while IFS= read -r changed; do
if [[ "${changed}" == "${PM_SYNC_TRACKER}/"*/*.toon ]]; then
item_id="${changed##*/}"
item_id="${item_id%.toon}"
# The default-branch link is the permanent one after merge; the branch link
# shows the proposed item while the review PR is open.
printf -- '- [%s](https://github.com/%s/blob/%s/%s) ([proposed](https://github.com/%s/blob/%s/%s))\n' "${item_id}" "${REPOSITORY}" "${DEFAULT_BRANCH}" "${changed}" "${REPOSITORY}" "${SYNC_BRANCH}" "${changed}" >> "${links_file}"
fi
done < <(git diff --cached --name-only -- "${sync_scope[0]}" "${sync_scope[@]:2}")
if [[ ! -s "${links_file}" ]]; then
echo "::error::Closeout changed tracker state but no item link could be generated."
exit 1
fi
# Publish only actionable decisions and counts, with no API prose.
summary_file="${RUNNER_TEMP}/pm-github-closeout-summary.json"
jq '{plan: [.plan[] | select(.code != "already_terminal" and .code != "other_repository" and .code != "duplicate_selection" and .code != "explicit_skip") | {item, pr, action: (if .action == "close" then "close" else "refuse" end), code}], skipped: ([.plan[] | select(.code == "already_terminal" or .code == "other_repository" or .code == "duplicate_selection" or .code == "explicit_skip")] | group_by(.code) | map({key: .[0].code, value: length}) | from_entries)}' "${RUNNER_TEMP}/pm-github-closeout.json" > "${summary_file}"
# Scan the public summary as added diff content before any publication.
summary_patch="${RUNNER_TEMP}/pm-github-closeout-summary.patch"
{
printf '%s\n' '--- /dev/null' '+++ b/closeout-summary.json'
printf '@@ -0,0 +1,%s @@\n' "$(wc -l < "${summary_file}")"
sed 's/^/+/' "${summary_file}"
} > "${summary_patch}"
pm github gate --diff "${summary_patch}" --json
# Re-enumerate all status paths, re-stage and re-scan immediately before
# committing. --only prevents any unrelated staged path from leaking
# even if the scope check is bypassed. NUL paths preserve odd filenames.
gate_sync_change
restore_sync_setup
check_sync_scope
git --literal-pathspecs commit --only --pathspec-from-file="${RUNNER_TEMP}/pm-github-sync-commit-paths" --pathspec-file-nul -m "chore(pm): close items delivered by merged pull requests"
gh auth setup-git
git push --force-with-lease="refs/heads/${SYNC_BRANCH}:${SYNC_LEASE}" origin "HEAD:refs/heads/${SYNC_BRANCH}"
body_file="${RUNNER_TEMP}/pm-github-sync-pr.md"
{
printf '%s\n\n' "Automated pm-github merged-PR closeout for ${REPOSITORY}."
printf '%s\n\n' "The closeout command verified merged-PR delivery and head checks. The proposed tracker change and regenerated changelog passed the fail-closed privacy gate before anything was committed. \`${DEFAULT_BRANCH}\` is never pushed directly."
printf '%s\n\n' "Tracker items changed:"
cat "${links_file}"
printf '\n%s\n\n%s\n' 'Closeout plan (routine skips counted by reason):' '```json'
cat "${summary_file}"
printf '%s\n' '```'
} > "${body_file}"
pr_number="$(own_open_pr)"
if [[ -z "${pr_number}" ]]; then
gh pr create -R "${REPOSITORY}" --base "${DEFAULT_BRANCH}" --head "${SYNC_BRANCH}" --title "chore(pm): close items delivered by merged pull requests" --body-file "${body_file}"
else
gh pr edit "${pr_number}" -R "${REPOSITORY}" --title "chore(pm): close items delivered by merged pull requests" --body-file "${body_file}"
fi
pr_number="$(own_open_pr)"
[[ "${pr_number}" =~ ^[0-9]+$ ]] || { echo "::error::Cannot resolve the owned automation PR number."; exit 1; }
export SYNC_PR_NUMBER="${pr_number}"
printf 'SYNC_PR_NUMBER=%s\n' "${pr_number}" >> "${GITHUB_ENV}"
export SYNC_PUBLISHED=true
printf 'SYNC_PUBLISHED=true\n' >> "${GITHUB_ENV}"
- name: Dispatch caller CI and report parked runs
if: env.SYNC_PUBLISHED == 'true'
env:
GH_TOKEN: ${{ steps.automation_token.outputs.token || github.token }}
MAINTENANCE_ATTEMPTS: ${{ steps.automation_token.outputs.token != '' && '40' || '120' }}
MAINTENANCE_SECONDS: ${{ steps.automation_token.outputs.token != '' && '2400' || '7200' }}
APP_DEADLINE: ${{ steps.publication_auth.outputs.deadline }}
shell: bash
run: |
set -Eeuo pipefail
trap 'echo "::error::Caller check dispatch or PR maintenance failed; token requires actions:write, contents:write and pull-requests:write, and selected workflows must support workflow_dispatch. Inspect the bot PR state; no protection was bypassed."' ERR
[[ "${SYNC_PUBLISHED:-false}" == true ]] || exit 0
# Default inventory covers the fleet's CI and CodeQL names. An explicit
# list replaces it; empty disables dispatch AND automatic landing.
if [[ -z "${CI_WORKFLOWS}" ]]; then
echo "::notice::Caller CI dispatch and automatic landing are disabled by ci-workflows."
exit 0
fi
workflows=()
while IFS= read -r workflow_file; do
[[ -n "${workflow_file}" ]] || continue
[[ "${workflow_file}" =~ ^[a-zA-Z0-9_.-]+\.ya?ml$ ]] || { echo "::error::ci-workflows must contain workflow filenames."; exit 1; }
if [[ ! -f ".github/workflows/${workflow_file}" ]]; then
echo "::notice::Skipping missing caller workflow: ${workflow_file}"
continue
fi
# Standard block and compact trigger forms; nested job/input keys
# cannot make a workflow dispatchable. Unknown forms fail closed.
if ! awk '
/^(on|"on"|\047on\047) *: *workflow_dispatch *(#.*)?$/ { found=1 }
/^(on|"on"|\047on\047) *: *\[([^]]*, *)?workflow_dispatch *(,|\])/ { found=1 }
/^(on|"on"|\047on\047) *: *($|#)/ { in_on=1; depth=-1; next }
/^[^[:space:]#]/ && !/^- +/ { in_on=0 }
in_on && /^[[:space:]]*[^[:space:]#]/ {
match($0, /^[[:space:]]*/)
if (depth < 0) depth=RLENGTH
if (RLENGTH == depth) {
rest=substr($0, depth + 1); sub(/^- +/, "", rest)
if (rest ~ /^workflow_dispatch *(:|$|#)/) found=1
}
}
END { exit !found }
' ".github/workflows/${workflow_file}"; then
echo "::warning::Skipping non-dispatchable caller workflow: ${workflow_file}. Add workflow_dispatch: under on: on the default branch for manual head validation. Eligible native PR checks must still pass."
continue
fi
mapfile -O "${#workflows[@]}" -t workflows < <(printf '%s\n' "${workflow_file}")
done < <(printf '%s\n' "${CI_WORKFLOWS}" | tr ' \t\r' '\n')
if [[ ${#workflows[@]} == 0 ]]; then
echo "::error::No dispatchable caller workflow; automatic landing refused."
exit 1
fi
# Dispatch every selected workflow before requesting protected auto-merge.
dispatch_checks() {
local workflow_file
for workflow_file in "${workflows[@]}"; do
gh workflow run "${workflow_file}" -R "${REPOSITORY}" --ref "${SYNC_BRANCH}"
done
}
# --auto respects required checks and conversation resolution. The SHA
# guard refuses a changed head; a disabled repository setting is soft.
enable_auto_merge() {
if [[ -z "${APP_DEADLINE:-}" ]]; then
echo "::notice::Auto-merge not armed with GITHUB_TOKEN: its merge does not start push workflows on the default branch. Configure the optional automation App for unattended landing."
return 0
fi
if ! gh pr merge "${SYNC_PR_NUMBER}" -R "${REPOSITORY}" --auto --squash --match-head-commit "${head_sha}"; then
echo "::notice::Squash auto-merge unavailable for the bot PR; repository settings or branch rules require attention. No protection was bypassed."
fi
}
head_sha="$(git rev-parse HEAD)"
dispatch_checks
enable_auto_merge
parked_runs="$(gh run list -R "${REPOSITORY}" --commit "${head_sha}" --status action_required --limit 100 --json url --jq '.[].url')"
while IFS= read -r run_url; do
[[ -n "${run_url}" ]] || continue
echo "::notice::A pull-request workflow requires maintainer approval: ${run_url}. Dispatched head checks do not satisfy PR branch rules; the optional automation App starts eligible native PR runs."
done <<< "${parked_runs}"
# A daily release can advance the base AFTER publication. Keep this
# same daily run alive rather than adding push triggers. App tokens
# expire after one hour, so their monitor stops within forty minutes.
# Concurrency serializes runs of this kind; it is not a daily quota.
last_dispatched="${head_sha}"
update_requested=""
maintenance_deadline=$((SECONDS + ${MAINTENANCE_SECONDS:-7200}))
if [[ -n "${APP_DEADLINE:-}" ]]; then
remaining_auth_seconds=$((APP_DEADLINE - $(date +%s)))
(( remaining_auth_seconds > 0 )) || remaining_auth_seconds=0
maintenance_deadline=$((SECONDS + remaining_auth_seconds))
fi
base_ref="$(jq -nr --arg ref "${DEFAULT_BRANCH}" '$ref | @uri')"
max_attempts="${MAINTENANCE_ATTEMPTS:-120}"
for ((attempt=0; attempt<max_attempts; attempt++)); do
(( SECONDS < maintenance_deadline )) || break
if ! pr="$(gh pr view "${SYNC_PR_NUMBER}" -R "${REPOSITORY}" --json number,state,headRefOid)"; then
echo "::notice::Bot PR state unavailable; retrying."
sleep 60
continue
fi
[[ "$(jq -r '.state' <<< "${pr}")" == OPEN ]] || exit 0
head_sha="$(jq -er '.headRefOid | select(test("^[a-f0-9]{40}$"))' <<< "${pr}")"
if [[ "${head_sha}" != "${last_dispatched}" ]]; then
dispatch_checks
enable_auto_merge
last_dispatched="${head_sha}"
update_requested=""
fi
# Merge-state labels can be BLOCKED or UNKNOWN while the base has
# advanced. Contents-read ancestry is independent of that policy label.
if ! behind_by="$(gh api "repos/${REPOSITORY}/compare/${base_ref}...${head_sha}" --jq '.behind_by')"; then
echo "::notice::Bot branch ancestry unavailable; retrying."
sleep 60
continue
fi
[[ "${behind_by}" =~ ^[0-9]+$ ]] || { echo "::error::Cannot verify bot branch ancestry."; exit 1; }
if (( behind_by > 0 )) && [[ "${update_requested}" != "${head_sha}" ]]; then
number="$(jq -er '.number | select(type == "number")' <<< "${pr}")"
# GitHub merges the current protected base into this same-repo bot
# branch. expected_head_sha prevents racing another branch writer.
if gh api --method PUT "repos/${REPOSITORY}/pulls/${number}/update-branch" -f "expected_head_sha=${head_sha}" >/dev/null; then
update_requested="${head_sha}"
echo "::notice::Updating the behind bot branch; checks will be dispatched again when the new head appears."
else
echo "::notice::Bot branch update refused; retrying without bypassing protections."
fi
fi
sleep 60
done
echo "::notice::Bot PR maintenance reached its configured bound; auto-merge remains enabled when allowed. The next scheduled run rebuilds from the current default branch. Unresolved conversations still require resolution."