@plainconceptsplatform/workflows 0.19.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/catalog-installation.js +12 -1
  2. package/dist/stack-defaults.js +16 -16
  3. package/dist/worker-env.js +14 -0
  4. package/loops/actions/add-issue-labels/action.yml +50 -50
  5. package/loops/actions/agent-output.cjs +17 -17
  6. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  7. package/loops/actions/apply-agent-comments/action.yml +42 -42
  8. package/loops/actions/apply-agent-labels/action.yml +55 -55
  9. package/loops/actions/apply-agent-output/action.yml +108 -108
  10. package/loops/actions/assess-blast-radius/action.yml +148 -0
  11. package/loops/actions/assess-blast-radius/assess-blast-radius.sh +138 -0
  12. package/loops/actions/classify-route/action.yml +100 -100
  13. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  14. package/loops/actions/close-agent-issues/action.yml +43 -43
  15. package/loops/actions/create-agent-issues/action.yml +52 -52
  16. package/loops/actions/create-issue-comment/action.yml +29 -29
  17. package/loops/actions/download-agent-output/action.yml +53 -53
  18. package/loops/actions/housekeeping/action.yml +55 -2
  19. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  20. package/loops/actions/list-open-issues/action.yml +33 -33
  21. package/loops/actions/load-issue-context/action.yml +45 -45
  22. package/loops/actions/merge-agent-pr/action.yml +49 -49
  23. package/loops/actions/push-agent-branch/action.yml +45 -45
  24. package/loops/actions/remove-issue-labels/action.yml +37 -37
  25. package/loops/actions/update-agent-issues/action.yml +58 -58
  26. package/loops/actions/validate-merge-gate-output/action.yml +62 -40
  27. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +147 -31
  28. package/loops/actions/validate-refine-output/action.yml +48 -44
  29. package/loops/actions/validate-refine-output/validate-refine-output.sh +15 -4
  30. package/loops/actions/validate-review-output/action.yml +35 -35
  31. package/loops/actions/validate-triage-output/action.yml +36 -36
  32. package/loops/actions/verify-composite-actions/action.yml +9 -9
  33. package/loops/actions/verify-refine-output/action.yml +9 -9
  34. package/loops/actions/verify-refine-output/verify-refine-output.sh +6 -1
  35. package/loops/actions/verify-route-matrix/action.yml +9 -9
  36. package/loops/actions/verify-route-matrix/verify-gate-metrics.mjs +51 -0
  37. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +329 -29
  38. package/loops/scripts/compile-agent-workflows.mjs +331 -331
  39. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  40. package/loops/templates/ci/app-ci-dotnet-next.yml +330 -330
  41. package/loops/templates/ci/app-ci-node-monorepo.yml +260 -260
  42. package/loops/templates/issues/bug_report.yml +109 -109
  43. package/loops/templates/issues/feature_request.yml +75 -75
  44. package/loops/templates/opencode/opencode.ci.json +55 -49
  45. package/loops/templates/opencode/opencode.ci.json.md +59 -49
  46. package/loops/templates/release/github-release.yml +30 -30
  47. package/loops/workflows/agent-merge-gate.md +367 -148
  48. package/loops/workflows/agent-refine.md +60 -16
  49. package/loops/workflows/authorize-bot-work.yml +105 -105
  50. package/loops/workflows/shared/opencode-ci.md +206 -206
  51. package/loops/workflows/shared/platform-defaults.md +19 -19
  52. package/package.json +1 -1
@@ -1,108 +1,108 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/apply-agent-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Apply agent output
3
- description: Download the agent artifact and apply its safe outputs in one fixed order, so every worker concludes the same way.
4
- inputs:
5
- token:
6
- description: GitHub token the writes are made with.
7
- required: true
8
- artifact-name:
9
- description: >-
10
- Name of the agent artifact. Callers pass the activation job's artifact_prefix output
11
- followed by "agent"; gh-aw derives that prefix from the workflow's inputs, so it is
12
- non-empty for every workflow_call worker and the bare name never resolves.
13
- required: true
14
- merge-pull-request:
15
- description: Apply merge_pull_request items.
16
- required: false
17
- default: 'false'
18
- push-to-branch:
19
- description: Apply push_to_pull_request_branch items.
20
- required: false
21
- default: 'false'
22
- close-issues:
23
- description: Apply close_issue items.
24
- required: false
25
- default: 'false'
26
- create-issues:
27
- description: Apply create_issue items.
28
- required: false
29
- default: 'false'
30
- update-issues:
31
- description: Apply update_issue items.
32
- required: false
33
- default: 'false'
34
- apply-labels:
35
- description: Apply add_labels and remove_labels items.
36
- required: false
37
- default: 'true'
38
- fallback-issue-number:
39
- description: Issue an update_issue item targets when it names none.
40
- required: false
41
- default: ''
42
- outputs:
43
- item-count:
44
- description: Number of items the agent produced.
45
- value: ${{ steps.output.outputs.item-count }}
46
- first-issue-number:
47
- description: Issue number of the first issue created, or empty.
48
- value: ${{ steps.create-issues.outputs.first-issue-number }}
49
- runs:
50
- using: composite
51
- steps:
52
- - name: Download agent output
53
- id: output
54
- uses: ./.github/actions/download-agent-output
55
- with:
56
- artifact-name: ${{ inputs.artifact-name }}
57
-
58
- - name: Merge pull request
59
- if: inputs.merge-pull-request == 'true' && steps.output.outputs.item-count != '0'
60
- uses: ./.github/actions/merge-agent-pr
61
- with:
62
- output-file: ${{ steps.output.outputs.output-file }}
63
- token: ${{ inputs.token }}
64
-
65
- - name: Push to pull request branch
66
- if: inputs.push-to-branch == 'true' && steps.output.outputs.item-count != '0'
67
- uses: ./.github/actions/push-agent-branch
68
- with:
69
- output-file: ${{ steps.output.outputs.output-file }}
70
- bundle-file: ${{ steps.output.outputs.bundle-file }}
71
- token: ${{ inputs.token }}
72
-
73
- - name: Close issues
74
- if: inputs.close-issues == 'true' && steps.output.outputs.item-count != '0'
75
- uses: ./.github/actions/close-agent-issues
76
- with:
77
- output-file: ${{ steps.output.outputs.output-file }}
78
- token: ${{ inputs.token }}
79
-
80
- - name: Update issues
81
- if: inputs.update-issues == 'true' && steps.output.outputs.item-count != '0'
82
- uses: ./.github/actions/update-agent-issues
83
- with:
84
- output-file: ${{ steps.output.outputs.output-file }}
85
- token: ${{ inputs.token }}
86
- fallback-issue-number: ${{ inputs.fallback-issue-number }}
87
-
88
- - name: Create issues
89
- id: create-issues
90
- if: inputs.create-issues == 'true' && steps.output.outputs.item-count != '0'
91
- uses: ./.github/actions/create-agent-issues
92
- with:
93
- output-file: ${{ steps.output.outputs.output-file }}
94
- token: ${{ inputs.token }}
95
-
96
- - name: Post agent comments
97
- if: steps.output.outputs.item-count != '0'
98
- uses: ./.github/actions/apply-agent-comments
99
- with:
100
- output-file: ${{ steps.output.outputs.output-file }}
101
- token: ${{ inputs.token }}
102
-
103
- - name: Apply agent label changes
104
- if: inputs.apply-labels == 'true' && steps.output.outputs.item-count != '0'
105
- uses: ./.github/actions/apply-agent-labels
106
- with:
107
- output-file: ${{ steps.output.outputs.output-file }}
108
- token: ${{ inputs.token }}
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/apply-agent-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Apply agent output
3
+ description: Download the agent artifact and apply its safe outputs in one fixed order, so every worker concludes the same way.
4
+ inputs:
5
+ token:
6
+ description: GitHub token the writes are made with.
7
+ required: true
8
+ artifact-name:
9
+ description: >-
10
+ Name of the agent artifact. Callers pass the activation job's artifact_prefix output
11
+ followed by "agent"; gh-aw derives that prefix from the workflow's inputs, so it is
12
+ non-empty for every workflow_call worker and the bare name never resolves.
13
+ required: true
14
+ merge-pull-request:
15
+ description: Apply merge_pull_request items.
16
+ required: false
17
+ default: 'false'
18
+ push-to-branch:
19
+ description: Apply push_to_pull_request_branch items.
20
+ required: false
21
+ default: 'false'
22
+ close-issues:
23
+ description: Apply close_issue items.
24
+ required: false
25
+ default: 'false'
26
+ create-issues:
27
+ description: Apply create_issue items.
28
+ required: false
29
+ default: 'false'
30
+ update-issues:
31
+ description: Apply update_issue items.
32
+ required: false
33
+ default: 'false'
34
+ apply-labels:
35
+ description: Apply add_labels and remove_labels items.
36
+ required: false
37
+ default: 'true'
38
+ fallback-issue-number:
39
+ description: Issue an update_issue item targets when it names none.
40
+ required: false
41
+ default: ''
42
+ outputs:
43
+ item-count:
44
+ description: Number of items the agent produced.
45
+ value: ${{ steps.output.outputs.item-count }}
46
+ first-issue-number:
47
+ description: Issue number of the first issue created, or empty.
48
+ value: ${{ steps.create-issues.outputs.first-issue-number }}
49
+ runs:
50
+ using: composite
51
+ steps:
52
+ - name: Download agent output
53
+ id: output
54
+ uses: ./.github/actions/download-agent-output
55
+ with:
56
+ artifact-name: ${{ inputs.artifact-name }}
57
+
58
+ - name: Merge pull request
59
+ if: inputs.merge-pull-request == 'true' && steps.output.outputs.item-count != '0'
60
+ uses: ./.github/actions/merge-agent-pr
61
+ with:
62
+ output-file: ${{ steps.output.outputs.output-file }}
63
+ token: ${{ inputs.token }}
64
+
65
+ - name: Push to pull request branch
66
+ if: inputs.push-to-branch == 'true' && steps.output.outputs.item-count != '0'
67
+ uses: ./.github/actions/push-agent-branch
68
+ with:
69
+ output-file: ${{ steps.output.outputs.output-file }}
70
+ bundle-file: ${{ steps.output.outputs.bundle-file }}
71
+ token: ${{ inputs.token }}
72
+
73
+ - name: Close issues
74
+ if: inputs.close-issues == 'true' && steps.output.outputs.item-count != '0'
75
+ uses: ./.github/actions/close-agent-issues
76
+ with:
77
+ output-file: ${{ steps.output.outputs.output-file }}
78
+ token: ${{ inputs.token }}
79
+
80
+ - name: Update issues
81
+ if: inputs.update-issues == 'true' && steps.output.outputs.item-count != '0'
82
+ uses: ./.github/actions/update-agent-issues
83
+ with:
84
+ output-file: ${{ steps.output.outputs.output-file }}
85
+ token: ${{ inputs.token }}
86
+ fallback-issue-number: ${{ inputs.fallback-issue-number }}
87
+
88
+ - name: Create issues
89
+ id: create-issues
90
+ if: inputs.create-issues == 'true' && steps.output.outputs.item-count != '0'
91
+ uses: ./.github/actions/create-agent-issues
92
+ with:
93
+ output-file: ${{ steps.output.outputs.output-file }}
94
+ token: ${{ inputs.token }}
95
+
96
+ - name: Post agent comments
97
+ if: steps.output.outputs.item-count != '0'
98
+ uses: ./.github/actions/apply-agent-comments
99
+ with:
100
+ output-file: ${{ steps.output.outputs.output-file }}
101
+ token: ${{ inputs.token }}
102
+
103
+ - name: Apply agent label changes
104
+ if: inputs.apply-labels == 'true' && steps.output.outputs.item-count != '0'
105
+ uses: ./.github/actions/apply-agent-labels
106
+ with:
107
+ output-file: ${{ steps.output.outputs.output-file }}
108
+ token: ${{ inputs.token }}
@@ -0,0 +1,148 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/assess-blast-radius/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Assess blast radius
3
+ description: Classify a pull request's potential impact from its changed paths and diff shape, before any model reads it.
4
+ inputs:
5
+ token:
6
+ description: GitHub token with pull-requests read access.
7
+ required: true
8
+ pr-number:
9
+ description: Pull request number to assess.
10
+ required: true
11
+ protected-paths:
12
+ description: Extended regular expression. A match may never auto-merge and forces HIGH.
13
+ required: true
14
+ owner-paths:
15
+ description: Extended regular expression. A match forces HIGH and requires an owner.
16
+ required: false
17
+ default: ''
18
+ sensitive-paths:
19
+ description: Extended regular expression. A match raises the floor to MEDIUM.
20
+ required: false
21
+ default: ''
22
+ high-files:
23
+ description: Files changed at or above which the radius is HIGH.
24
+ required: false
25
+ default: '20'
26
+ high-lines:
27
+ description: Lines changed at or above which the radius is HIGH.
28
+ required: false
29
+ default: '800'
30
+ medium-files:
31
+ description: Files changed at or above which the radius is MEDIUM.
32
+ required: false
33
+ default: '5'
34
+ medium-lines:
35
+ description: Lines changed at or above which the radius is MEDIUM.
36
+ required: false
37
+ default: '200'
38
+ outputs:
39
+ level:
40
+ description: "low, medium or high."
41
+ value: ${{ steps.assess.outputs.level }}
42
+ signals:
43
+ description: One line per signal that fired, in the order the rules ran.
44
+ value: ${{ steps.assess.outputs.signals }}
45
+ requires_review:
46
+ description: Whether a protected path was touched. Kept for the jobs that already read it.
47
+ value: ${{ steps.assess.outputs.requires_review }}
48
+ files:
49
+ description: The protected paths that matched, one per line. Empty when none did.
50
+ value: ${{ steps.assess.outputs.files }}
51
+ owner_hits:
52
+ description: The owner paths that matched, one per line.
53
+ value: ${{ steps.assess.outputs.owner_hits }}
54
+ owner_hit:
55
+ description: Whether an owner path was touched. A scalar, so no caller has to compare a multi-line value to an empty string.
56
+ value: ${{ steps.assess.outputs.owner_hit }}
57
+ sensitive_hit:
58
+ description: Whether a sensitive path was touched.
59
+ value: ${{ steps.assess.outputs.sensitive_hit }}
60
+ sensitive_hits:
61
+ description: The sensitive paths that matched, one per line.
62
+ value: ${{ steps.assess.outputs.sensitive_hits }}
63
+ required_owners:
64
+ description: CODEOWNERS entries covering the changed paths, space separated. Empty when the repository has no CODEOWNERS.
65
+ value: ${{ steps.assess.outputs.required_owners }}
66
+ files_changed:
67
+ description: Number of files in the diff.
68
+ value: ${{ steps.assess.outputs.files_changed }}
69
+ lines_changed:
70
+ description: Additions plus deletions.
71
+ value: ${{ steps.assess.outputs.lines_changed }}
72
+ runs:
73
+ using: composite
74
+ steps:
75
+ - name: Assess blast radius
76
+ id: assess
77
+ shell: bash
78
+ env:
79
+ GH_TOKEN: ${{ inputs.token }}
80
+ REPO: ${{ github.repository }}
81
+ PR_NUMBER: ${{ inputs.pr-number }}
82
+ PROTECTED_PATHS: ${{ inputs.protected-paths }}
83
+ OWNER_PATHS: ${{ inputs.owner-paths }}
84
+ SENSITIVE_PATHS: ${{ inputs.sensitive-paths }}
85
+ HIGH_FILES: ${{ inputs.high-files }}
86
+ HIGH_LINES: ${{ inputs.high-lines }}
87
+ MEDIUM_FILES: ${{ inputs.medium-files }}
88
+ MEDIUM_LINES: ${{ inputs.medium-lines }}
89
+ run: |
90
+ set -euo pipefail
91
+ # One API call. The job this replaced already paid for it to grep PROTECTED_PATHS, and
92
+ # the additions and deletions come back in the same payload, so the diff shape costs
93
+ # nothing extra.
94
+ gh api --paginate "repos/$REPO/pulls/$PR_NUMBER/files?per_page=100" \
95
+ --jq '[.[] | {filename, additions, deletions}]' > /tmp/blast-files.json
96
+
97
+ files=$(jq -r '.[].filename' /tmp/blast-files.json)
98
+ files_changed=$(jq -r 'length' /tmp/blast-files.json)
99
+ lines_changed=$(jq -r '[.[] | .additions + .deletions] | add // 0' /tmp/blast-files.json)
100
+
101
+ bash "${{ github.action_path }}/assess-blast-radius.sh" \
102
+ "$files_changed" "$lines_changed" <<<"$files" > /tmp/blast-out.env
103
+
104
+ cat /tmp/blast-out.env >> "$GITHUB_OUTPUT"
105
+
106
+ # CODEOWNERS refines who is asked; it never decides whether anyone is. A repository
107
+ # without the file still gets an owner disposition from OWNER_PATHS, and the comment
108
+ # names the paths instead of a team.
109
+ #
110
+ # The pattern is a gitignore-style glob, not a regex. Stripping a leading slash and a
111
+ # trailing `/**` and feeding the rest to grep -E was wrong in both directions: `apps/web/`
112
+ # matched `apps/webhooks/x.ts` because nothing anchored the segment end, `docs/*` matched
113
+ # `docsecret.md` because `*` applied to the slash, and `*.ts` became `^*.ts`, which GNU
114
+ # grep parses as a literal with the anchor dropped, so it matched `src/payments/Fee.cs`.
115
+ # The result was over-broad `required_owners`: teams asked to review changes they do not
116
+ # own. Translate the glob properly instead.
117
+ owners=""
118
+ glob_to_regex() {
119
+ printf '%s' "$1" | sed -e 's/[.[\$()|+^{}]/\\&/g' \
120
+ -e 's#\*\*/#\x01#g' -e 's#/\*\*#\x02#g' \
121
+ -e 's#\*#[^/]*#g' -e 's#?#[^/]#g' \
122
+ -e 's#\x01#(.*/)?#g' -e 's#\x02#(/.*)?#g'
123
+ }
124
+ for candidate in CODEOWNERS .github/CODEOWNERS docs/CODEOWNERS; do
125
+ [ -f "$candidate" ] || continue
126
+ # `|| [ -n "$pattern" ]` so a last line with no trailing newline is still read: plain
127
+ # `while read` returns 1 on EOF and drops it, which silently loses the final rule.
128
+ while read -r pattern rest || [ -n "$pattern" ]; do
129
+ case "$pattern" in ''|'#'*) continue ;; esac
130
+ [ -n "$rest" ] || continue
131
+ anchored=0
132
+ case "$pattern" in /*) anchored=1 ;; esac
133
+ glob="${pattern#/}"; glob="${glob%/}"
134
+ regex="$(glob_to_regex "$glob")"
135
+ # A directory rule owns everything beneath it; a file rule matches the path exactly.
136
+ case "$pattern" in
137
+ */|*/\*\*) regex="${regex}(/.*)?$" ;;
138
+ *) regex="${regex}(/.*)?$" ;;
139
+ esac
140
+ # An unanchored CODEOWNERS pattern matches at any depth, an anchored one only at the root.
141
+ if [ "$anchored" -eq 1 ]; then regex="^${regex}"; else regex="(^|/)${regex}"; fi
142
+ if printf '%s\n' "$files" | grep -qE "$regex"; then
143
+ owners="$owners $rest"
144
+ fi
145
+ done < "$candidate"
146
+ break
147
+ done
148
+ echo "required_owners=$(echo "$owners" | tr -s ' ' | sed 's/^ //;s/ $//')" >> "$GITHUB_OUTPUT"
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env bash
2
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/assess-blast-radius/assess-blast-radius.sh. Update with `workflows update --force`; consumer edits may be overwritten.
3
+ # Classify a pull request's blast radius from its changed paths and diff shape.
4
+ #
5
+ # Reads the changed paths on stdin, one per line. Prints GITHUB_OUTPUT lines on stdout.
6
+ # Deliberately model-free: the point of this file is that the most consequential input to the
7
+ # merge decision is computed from facts a shell can check, before any agent reads the diff.
8
+ #
9
+ # Usage: assess-blast-radius.sh <files_changed> <lines_changed> < paths
10
+ # Regexes and thresholds arrive in the environment so the caller and the tests share one contract.
11
+
12
+ set -euo pipefail
13
+
14
+ files_changed="${1:-0}"
15
+ lines_changed="${2:-0}"
16
+
17
+ PROTECTED_PATHS="${PROTECTED_PATHS:-}"
18
+ OWNER_PATHS="${OWNER_PATHS:-}"
19
+ SENSITIVE_PATHS="${SENSITIVE_PATHS:-}"
20
+ HIGH_FILES="${HIGH_FILES:-20}"
21
+ HIGH_LINES="${HIGH_LINES:-800}"
22
+ MEDIUM_FILES="${MEDIUM_FILES:-5}"
23
+ MEDIUM_LINES="${MEDIUM_LINES:-200}"
24
+
25
+ files="$(cat)"
26
+
27
+ # An empty regex matches every line in grep -E, which would mark every pull request protected.
28
+ # A list nobody configured must match nothing, not everything.
29
+ #
30
+ # The `|| true` that used to end this function swallowed grep exit 2 (a regex that does not
31
+ # compile) exactly as it swallowed exit 1 (no match), so a consumer typo in PROTECTED_PATHS
32
+ # reported "no protected paths touched" and merged. Consumers are told to edit all three of
33
+ # these, so the typo is the likely case. Each pattern is compiled once below, in this shell,
34
+ # before any of them is used: a `match` failure cannot report itself, because `match` runs
35
+ # inside a command substitution and anything it sets dies with the subshell.
36
+ match() {
37
+ local pattern="$1"
38
+ [ -n "$pattern" ] || return 0
39
+ printf '%s\n' "$files" | grep -E "$pattern" || true
40
+ }
41
+
42
+ # Compile each configured pattern against nothing. grep exits 2 when the regex is bad, which is
43
+ # distinct from 1 for no match, and that is the whole signal.
44
+ for name in PROTECTED_PATHS OWNER_PATHS SENSITIVE_PATHS; do
45
+ pattern="${!name}"
46
+ [ -n "$pattern" ] || continue
47
+ if printf '' | grep -E "$pattern" >/dev/null 2>&1; then :; elif [ "$?" -gt 1 ]; then
48
+ echo "assess-blast-radius: ${name} is not a valid extended regular expression: ${pattern}" >&2
49
+ exit 2
50
+ fi
51
+ done
52
+
53
+ protected_hits="$(match "$PROTECTED_PATHS")"
54
+ owner_hits="$(match "$OWNER_PATHS")"
55
+ sensitive_hits="$(match "$SENSITIVE_PATHS")"
56
+
57
+ # A threshold that is not a number turns its comparison into a shell error that `set -e` does
58
+ # not catch, because the test is an `if` condition: `HIGH_FILES=twenty` printed a diagnostic to
59
+ # stderr and quietly produced `level=low` for a diff of any size.
60
+ for threshold in HIGH_FILES HIGH_LINES MEDIUM_FILES MEDIUM_LINES files_changed lines_changed; do
61
+ case "${!threshold}" in
62
+ ""|*[!0-9]*)
63
+ echo "assess-blast-radius: ${threshold} must be a whole number, got '${!threshold}'" >&2
64
+ exit 2
65
+ ;;
66
+ esac
67
+ done
68
+
69
+ signals=""
70
+ add_signal() { signals="${signals}${signals:+$'\n'}$1"; }
71
+
72
+ level=low
73
+
74
+ if [ -n "$protected_hits" ]; then
75
+ level=high
76
+ add_signal "protected paths touched ($(printf '%s\n' "$protected_hits" | tr '\n' ' ' | sed 's/ $//'))"
77
+ fi
78
+
79
+ if [ -n "$owner_hits" ]; then
80
+ level=high
81
+ add_signal "owner paths touched ($(printf '%s\n' "$owner_hits" | tr '\n' ' ' | sed 's/ $//'))"
82
+ fi
83
+
84
+ if [ "$files_changed" -ge "$HIGH_FILES" ]; then
85
+ level=high
86
+ add_signal "$files_changed files changed, at or above the high threshold of $HIGH_FILES"
87
+ fi
88
+
89
+ if [ "$lines_changed" -ge "$HIGH_LINES" ]; then
90
+ level=high
91
+ add_signal "$lines_changed lines changed, at or above the high threshold of $HIGH_LINES"
92
+ fi
93
+
94
+ if [ "$level" != high ]; then
95
+ if [ -n "$sensitive_hits" ]; then
96
+ level=medium
97
+ add_signal "sensitive paths touched ($(printf '%s\n' "$sensitive_hits" | tr '\n' ' ' | sed 's/ $//'))"
98
+ fi
99
+ if [ "$files_changed" -ge "$MEDIUM_FILES" ]; then
100
+ level=medium
101
+ add_signal "$files_changed files changed, at or above the medium threshold of $MEDIUM_FILES"
102
+ fi
103
+ if [ "$lines_changed" -ge "$MEDIUM_LINES" ]; then
104
+ level=medium
105
+ add_signal "$lines_changed lines changed, at or above the medium threshold of $MEDIUM_LINES"
106
+ fi
107
+ fi
108
+
109
+ [ -n "$signals" ] || signals="no blast-radius signal fired: $files_changed files, $lines_changed lines, no configured path matched"
110
+
111
+ # A fixed heredoc delimiter is a fail-open here. Every multi-line value below is built from
112
+ # paths the pull request chose, so a path that is exactly the delimiter closes its block early
113
+ # and every line after it is read as a new output -- including `level=low`, which is emitted
114
+ # above and would be overridden by the later value. The shipped regexes cannot match a bare
115
+ # delimiter, but a consumer who writes a loose one can, and the failure is silent and merges.
116
+ # A per-run delimiter removes the class rather than the instance, which is what GitHub
117
+ # documents for exactly this reason.
118
+ DELIM="BLASTEOF_$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')"
119
+
120
+ emit_block() {
121
+ local name="$1" value="$2"
122
+ printf '%s<<%s\n%s\n%s\n' "$name" "$DELIM" "$value" "$DELIM"
123
+ }
124
+
125
+ echo "level=$level"
126
+ echo "files_changed=$files_changed"
127
+ echo "lines_changed=$lines_changed"
128
+ # Emitted as plain scalars, not left for the caller to derive by comparing a heredoc block to
129
+ # an empty string. Whether the runner renders an empty block as "" or as a newline is not
130
+ # something this repository can test off-runner, and a caller that guessed wrong would have sent
131
+ # every pull request to owner review. A shell that already knows the answer should just say it.
132
+ echo "requires_review=$([ -n "$protected_hits" ] && echo true || echo false)"
133
+ echo "owner_hit=$([ -n "$owner_hits" ] && echo true || echo false)"
134
+ echo "sensitive_hit=$([ -n "$sensitive_hits" ] && echo true || echo false)"
135
+ emit_block signals "$signals"
136
+ emit_block files "$protected_hits"
137
+ emit_block owner_hits "$owner_hits"
138
+ emit_block sensitive_hits "$sensitive_hits"