forgedock 1.2.0 → 1.4.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 (78) hide show
  1. package/README.md +8 -2
  2. package/bin/batch-runner.mjs +31 -2
  3. package/bin/engine/admission.mjs +294 -0
  4. package/bin/engine/admission.test.mjs +265 -0
  5. package/bin/engine/invariants.mjs +17 -4
  6. package/bin/engine/phases.mjs +358 -42
  7. package/bin/engine/resolve.mjs +256 -0
  8. package/bin/engine/resolve.test.mjs +149 -0
  9. package/bin/engine/state.mjs +31 -7
  10. package/bin/engine-cli.mjs +138 -2
  11. package/bin/engine.mjs +414 -21
  12. package/bin/forgedock.mjs +552 -44
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +189 -19
  15. package/bin/journey.mjs +109 -11
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +321 -10
  18. package/bin/watch-utils.mjs +255 -0
  19. package/commands/cleanup.md +7 -3
  20. package/commands/forge-stats.md +5 -4
  21. package/commands/issue.md +5 -1
  22. package/commands/orchestrate/config.md +41 -1
  23. package/commands/orchestrate/phase-1-resolve.md +234 -8
  24. package/commands/orchestrate/phase-3-dependency.md +27 -18
  25. package/commands/orchestrate/phase-4-execution.md +223 -22
  26. package/commands/qa-sweep.md +17 -0
  27. package/commands/quality-gate.md +6 -6
  28. package/commands/replay.md +16 -2
  29. package/commands/review-pr-agents/spec-cli.md +179 -0
  30. package/commands/review-pr.md +65 -0
  31. package/commands/upgrade-deps.md +523 -0
  32. package/commands/work-on/build/validate.md +61 -1
  33. package/commands/work-on/build.md +9 -1
  34. package/commands/work-on/decompose.md +10 -1
  35. package/commands/work-on/investigate.md +22 -4
  36. package/commands/work-on/remediate.md +2 -0
  37. package/commands/work-on/review.md +40 -1
  38. package/commands/work-on.md +19 -4
  39. package/docs/AIDER.md +264 -0
  40. package/docs/AUTOPILOT-CRON.md +130 -0
  41. package/docs/CONFIG.md +156 -0
  42. package/docs/CURSOR.md +135 -0
  43. package/docs/OPENCODE.md +312 -0
  44. package/docs/demo.gif +0 -0
  45. package/docs/eval/README.md +4 -4
  46. package/docs/launch/show-hn.md +3 -3
  47. package/docs/site/architecture.md +180 -0
  48. package/docs/site/command-reference.md +17 -0
  49. package/docs/site/configuration.md +243 -0
  50. package/docs/site/for-companies.md +4 -4
  51. package/docs/site/forge-annotation-protocol.md +1 -1
  52. package/docs/site/github-is-the-memory.md +2 -2
  53. package/docs/site/how-it-works.md +1 -1
  54. package/docs/site/public/llms-full.txt +380 -0
  55. package/docs/site/public/llms.txt +35 -0
  56. package/docs/spec/eval-run-result.md +8 -4
  57. package/docs/spec/label-state-machine.md +9 -0
  58. package/docs/spec-graph-schema.md +3 -3
  59. package/package.json +3 -3
  60. package/packages/protocol/README.md +75 -0
  61. package/packages/protocol/src/card.js +114 -0
  62. package/packages/protocol/src/cli.js +4 -77
  63. package/packages/protocol/src/emit.js +5 -11
  64. package/packages/protocol/src/html-comment-escape.js +68 -0
  65. package/packages/protocol/src/index.js +3 -1
  66. package/packages/protocol/src/parse.js +19 -11
  67. package/packages/protocol/src/phases.js +105 -0
  68. package/packages/protocol/src/types.js +18 -1
  69. package/packages/protocol/src/validate.js +11 -0
  70. package/scripts/check-admission-parity.mjs +167 -0
  71. package/scripts/check-command-side-effects.sh +376 -0
  72. package/scripts/check-phase-registry-drift.mjs +96 -0
  73. package/scripts/check-spec-markers.sh +274 -0
  74. package/scripts/classify-lane.sh +29 -7
  75. package/scripts/forge-run.sh +353 -0
  76. package/templates/cross-repo-trigger.yml +282 -0
  77. package/templates/cursor/forge.mdc +148 -0
  78. package/templates/github-actions/forgedock-autopilot.yml +113 -0
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env bash
2
+ # forge-run.sh — Universal shell-script entry point for non-Claude agents
3
+ #
4
+ # Usage: forge-run.sh <command> <issue_number> [-R <owner/repo>]
5
+ # command: Pipeline command to run (currently: work-on)
6
+ # issue_number: GitHub issue number (required)
7
+ # -R <owner/repo>: GitHub repository (optional, defaults to current repo)
8
+ #
9
+ # Description:
10
+ # GitHub-state-aware phase router. Reads workflow:* labels and FORGE annotation
11
+ # comments on the given issue to determine the current pipeline phase, then emits
12
+ # structured JSON events to stdout describing phase state and transitions.
13
+ #
14
+ # This script does NOT invoke LLM agents — it reports what phase the pipeline is
15
+ # in and what the next action should be. It is designed to be called by any
16
+ # agent runtime that can execute bash (Aider /run, Cursor terminal, CI runners,
17
+ # OpenCode shell) to get deterministic routing information without re-implementing
18
+ # the phase detection logic.
19
+ #
20
+ # JSON Event Schema:
21
+ # Each event is a single JSON object on its own line (newline-delimited JSON):
22
+ #
23
+ # Phase detection:
24
+ # {"event":"phase_detected","command":"work-on","issue":N,"phase":"<phase>","ts":"<ISO8601>"}
25
+ #
26
+ # Phase detail:
27
+ # {"event":"phase_detail","phase":"<phase>","lane":"<lane>","branch":"<branch>","labels":["<label>",...]}
28
+ #
29
+ # Action required:
30
+ # {"event":"action_required","phase":"<phase>","action":"<description>","ts":"<ISO8601>"}
31
+ #
32
+ # Terminal state:
33
+ # {"event":"terminal","phase":"<phase>","label":"<terminal-label>","issue":N,"ts":"<ISO8601>"}
34
+ #
35
+ # Error:
36
+ # {"event":"error","code":"<error_code>","message":"<message>","ts":"<ISO8601>"}
37
+ #
38
+ # Phase values (work-on command):
39
+ # no-comments — No investigation started; next: Phase 1 (investigate)
40
+ # investigating — Investigation in progress (partial comment, no INVESTIGATION:COMPLETE)
41
+ # ready-to-build — Investigation complete; next: Phase 3 (build)
42
+ # building — Build in progress (no FORGE:BUILDER comment yet)
43
+ # awaiting-pr — Builder complete, no PR; next: Phase 4 (create PR)
44
+ # in-review — PR exists, review in progress; next: Phase 5 (auto-review)
45
+ # merged — Terminal: workflow:merged label set
46
+ # invalid — Terminal: workflow:invalid label set
47
+ # decomposed — Terminal: workflow:decomposed label set (sub-issues spawned)
48
+ # needs-human — Terminal: needs-human label set
49
+ #
50
+ # Exit codes: 0 = success, 1 = error (invalid args, gh auth failure, etc.)
51
+ #
52
+ # <!-- Added: forge#965 -->
53
+
54
+ set -euo pipefail
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Helpers
58
+ # ---------------------------------------------------------------------------
59
+
60
+ # ISO 8601 timestamp (UTC)
61
+ ts() { date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "1970-01-01T00:00:00Z"; }
62
+
63
+ # Emit a JSON event to stdout (one object per line — NDJSON)
64
+ emit() {
65
+ local json="$1"
66
+ printf '%s\n' "$json"
67
+ }
68
+
69
+ # Emit an error event and exit 1
70
+ die() {
71
+ local code="$1"
72
+ local message="$2"
73
+ local ts_val
74
+ ts_val=$(ts)
75
+ # Route through json_str for full JSON-string escaping (backslash, quote, and
76
+ # control characters) — die() is fed raw multi-line `gh` CLI stderr, which may
77
+ # contain backslashes and newlines that a quote-only escape would not handle.
78
+ message=$(json_str "$message")
79
+ emit "{\"event\":\"error\",\"code\":\"${code}\",\"message\":\"${message}\",\"ts\":\"${ts_val}\"}"
80
+ exit 1
81
+ }
82
+
83
+ # JSON-escape a string: backslash, double-quote, and the full C0 control range
84
+ # (U+0000-U+001F), per RFC 8259 section 7. Order matters:
85
+ # 1. Backslash escaping runs first so a literal backslash becomes \\ before
86
+ # any other pass runs, avoiding double-escaping ambiguity.
87
+ # 2. Double-quote next.
88
+ # 3. The three named control escapes (\n, \r, \t) next, so they get their
89
+ # short two-character form rather than falling through to \u00XX.
90
+ # 4. Every remaining C0 control byte (U+0000-U+001F, excluding \n/\r/\t
91
+ # already handled above) is escaped as \u00XX — bash's ${s//pat/rep}
92
+ # cannot loop over an arbitrary byte range in one substitution, so this
93
+ # is a bounded loop over hex 00-1F.
94
+ json_str() {
95
+ local s="$1"
96
+ s="${s//\\/\\\\}"
97
+ s="${s//\"/\\\"}"
98
+ s="${s//$'\n'/\\n}"
99
+ s="${s//$'\r'/\\r}"
100
+ s="${s//$'\t'/\\t}"
101
+ local i
102
+ for ((i = 0; i <= 31; i++)); do
103
+ # \n (10=0x0a), \r (13=0x0d), \t (9=0x09) already escaped above — skip them
104
+ # here so they keep their short named form instead of becoming \u00XX.
105
+ if [ "$i" -eq 9 ] || [ "$i" -eq 10 ] || [ "$i" -eq 13 ]; then
106
+ continue
107
+ fi
108
+ local hex
109
+ hex=$(printf '%02x' "$i")
110
+ local ctrl_char
111
+ printf -v ctrl_char '\\x%02x' "$i"
112
+ ctrl_char=$(printf "$ctrl_char")
113
+ if [[ "$s" == *"$ctrl_char"* ]]; then
114
+ s="${s//$ctrl_char/\\u00$hex}"
115
+ fi
116
+ done
117
+ printf '%s' "$s"
118
+ }
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Argument parsing
122
+ # ---------------------------------------------------------------------------
123
+
124
+ COMMAND="${1:-}"
125
+ ISSUE_NUMBER="${2:-}"
126
+ GH_REPO_ARGS=()
127
+
128
+ if [ -z "$COMMAND" ]; then
129
+ die "MISSING_COMMAND" "command is required. Usage: forge-run.sh <command> <issue_number> [-R <owner/repo>]"
130
+ fi
131
+
132
+ if [ -z "$ISSUE_NUMBER" ]; then
133
+ die "MISSING_ISSUE" "issue_number is required. Usage: forge-run.sh <command> <issue_number> [-R <owner/repo>]"
134
+ fi
135
+
136
+ # Validate command
137
+ case "$COMMAND" in
138
+ work-on) ;;
139
+ *)
140
+ die "UNKNOWN_COMMAND" "unknown command: ${COMMAND}. Supported: work-on"
141
+ ;;
142
+ esac
143
+
144
+ # Validate issue number is numeric
145
+ if ! [[ "$ISSUE_NUMBER" =~ ^[0-9]+$ ]]; then
146
+ die "INVALID_ISSUE" "issue_number must be numeric, got: ${ISSUE_NUMBER}"
147
+ fi
148
+
149
+ # Parse remaining flags
150
+ shift 2
151
+ while [ $# -gt 0 ]; do
152
+ case "$1" in
153
+ -R)
154
+ if [ $# -lt 2 ]; then
155
+ die "MISSING_REPO" "-R requires a value <owner/repo>. Usage: forge-run.sh <command> <issue_number> [-R <owner/repo>]"
156
+ fi
157
+ if ! [[ "$2" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
158
+ die "INVALID_REPO" "-R value must be owner/repo format, got: ${2}"
159
+ fi
160
+ GH_REPO_ARGS=(-R "$2")
161
+ shift 2
162
+ ;;
163
+ *)
164
+ die "UNKNOWN_FLAG" "unknown argument: ${1}. Usage: forge-run.sh <command> <issue_number> [-R <owner/repo>]"
165
+ ;;
166
+ esac
167
+ done
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Locate scripts directory (where classify-lane.sh lives alongside this script)
171
+ # ---------------------------------------------------------------------------
172
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
173
+ CLASSIFY_LANE="${SCRIPT_DIR}/classify-lane.sh"
174
+
175
+ if [ ! -x "$CLASSIFY_LANE" ]; then
176
+ die "MISSING_SCRIPT" "classify-lane.sh not found or not executable at: ${CLASSIFY_LANE}"
177
+ fi
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Phase 1: Read issue state from GitHub
181
+ # ---------------------------------------------------------------------------
182
+
183
+ # Fetch issue: state, labels
184
+ GH_STDERR_TMP=$(mktemp)
185
+ ISSUE_JSON=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" \
186
+ --json number,state,labels,title \
187
+ 2>"$GH_STDERR_TMP") || {
188
+ MSG=$(cat "$GH_STDERR_TMP")
189
+ rm -f "$GH_STDERR_TMP"
190
+ die "GH_FETCH_FAILED" "failed to fetch issue #${ISSUE_NUMBER}: ${MSG}"
191
+ }
192
+ rm -f "$GH_STDERR_TMP"
193
+
194
+ # Extract state and labels
195
+ ISSUE_STATE=$(printf '%s' "$ISSUE_JSON" | jq -r '.state')
196
+ LABELS_JSON=$(printf '%s' "$ISSUE_JSON" | jq -c '[.labels[].name]')
197
+ ISSUE_TITLE=$(printf '%s' "$ISSUE_JSON" | jq -r '.title')
198
+
199
+ # Check if issue is closed
200
+ if [ "$ISSUE_STATE" = "CLOSED" ]; then
201
+ ts_val=$(ts)
202
+ emit "{\"event\":\"terminal\",\"phase\":\"closed\",\"label\":\"closed\",\"issue\":${ISSUE_NUMBER},\"ts\":\"${ts_val}\"}"
203
+ exit 0
204
+ fi
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Phase 2: Detect terminal labels
208
+ # ---------------------------------------------------------------------------
209
+
210
+ TERMINAL_LABEL=""
211
+ for label in "workflow:merged" "workflow:invalid" "workflow:decomposed" "needs-human"; do
212
+ if printf '%s' "$LABELS_JSON" | jq -e --arg l "$label" 'contains([$l])' > /dev/null 2>&1; then
213
+ TERMINAL_LABEL="$label"
214
+ break
215
+ fi
216
+ done
217
+
218
+ if [ -n "$TERMINAL_LABEL" ]; then
219
+ ts_val=$(ts)
220
+ emit "{\"event\":\"terminal\",\"phase\":\"${TERMINAL_LABEL}\",\"label\":\"$(json_str "${TERMINAL_LABEL}")\",\"issue\":${ISSUE_NUMBER},\"ts\":\"${ts_val}\"}"
221
+ exit 0
222
+ fi
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Phase 3: Read FORGE annotation comments to determine pipeline phase
226
+ # ---------------------------------------------------------------------------
227
+
228
+ # gh issue view with --json comments is the reliable approach: it accepts the -R flag
229
+ # via GH_REPO_ARGS and handles both default-repo and explicit-repo cases.
230
+ # gh api repos/{owner}/{repo}/... would need GH_REPO env var for -R override — avoided here.
231
+ GH_STDERR_TMP=$(mktemp)
232
+ COMMENTS_JSON=$(gh issue view "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" \
233
+ --json comments --jq '[.comments[] | {id: .id, body: .body}]' \
234
+ 2>"$GH_STDERR_TMP") || {
235
+ MSG=$(cat "$GH_STDERR_TMP")
236
+ rm -f "$GH_STDERR_TMP"
237
+ die "GH_COMMENTS_FAILED" "failed to fetch comments for issue #${ISSUE_NUMBER}: ${MSG}"
238
+ }
239
+ rm -f "$GH_STDERR_TMP" 2>/dev/null || true
240
+
241
+ if [ -z "$COMMENTS_JSON" ]; then
242
+ COMMENTS_JSON="[]"
243
+ fi
244
+
245
+ # Detect annotation presence
246
+ has_annotation() {
247
+ local marker="$1"
248
+ printf '%s' "$COMMENTS_JSON" | jq -e --arg m "$marker" 'any(.[]; .body | contains($m))' > /dev/null 2>&1
249
+ }
250
+
251
+ # Detect complete annotation (has both the annotation tag AND its completion sentinel)
252
+ is_annotation_complete() {
253
+ local open_tag="$1"
254
+ local sentinel="$2"
255
+ printf '%s' "$COMMENTS_JSON" | jq -e \
256
+ --arg o "$open_tag" --arg s "$sentinel" \
257
+ 'any(.[]; (.body | contains($o)) and (.body | contains($s)))' > /dev/null 2>&1
258
+ }
259
+
260
+ INVESTIGATOR_COMPLETE=false
261
+ BUILDER_COMPLETE=false
262
+ BUILDER_PRESENT=false
263
+ REVIEW_STARTED=false
264
+
265
+ if is_annotation_complete "FORGE:INVESTIGATOR" "INVESTIGATION:COMPLETE"; then
266
+ INVESTIGATOR_COMPLETE=true
267
+ fi
268
+ if has_annotation "FORGE:BUILDER"; then
269
+ BUILDER_PRESENT=true
270
+ fi
271
+ if is_annotation_complete "FORGE:BUILDER" "FORGE:BUILDER:COMPLETE"; then
272
+ BUILDER_COMPLETE=true
273
+ fi
274
+ if has_annotation "FORGE:REVIEW_STARTED"; then
275
+ REVIEW_STARTED=true
276
+ fi
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # Phase 4: Determine current workflow phase from labels + annotations
280
+ # ---------------------------------------------------------------------------
281
+
282
+ # Read current workflow labels
283
+ WORKFLOW_LABEL=""
284
+ for label in "workflow:in-review" "workflow:building" "workflow:ready-to-build" "workflow:investigating"; do
285
+ if printf '%s' "$LABELS_JSON" | jq -e --arg l "$label" 'contains([$l])' > /dev/null 2>&1; then
286
+ WORKFLOW_LABEL="$label"
287
+ break
288
+ fi
289
+ done
290
+
291
+ # Phase detection logic (matches work-on.md Phase 0B: "Determine resume point")
292
+ PHASE="no-comments"
293
+ ACTION=""
294
+
295
+ if [ "$REVIEW_STARTED" = "true" ]; then
296
+ PHASE="in-review"
297
+ ACTION="Review in progress — /review-pr handles merge. Check PR state."
298
+ elif [ "$BUILDER_COMPLETE" = "true" ] && [ "$WORKFLOW_LABEL" != "workflow:in-review" ]; then
299
+ PHASE="awaiting-pr"
300
+ ACTION="Builder complete. Create PR targeting the classified lane branch."
301
+ elif [ "$BUILDER_PRESENT" = "true" ] && [ "$BUILDER_COMPLETE" = "false" ]; then
302
+ PHASE="building"
303
+ ACTION="Builder comment present but incomplete. Resume build from FORGE:BUILDER comment."
304
+ elif [ "$WORKFLOW_LABEL" = "workflow:building" ]; then
305
+ PHASE="building"
306
+ ACTION="Build phase active. No FORGE:BUILDER comment yet — build not complete."
307
+ elif [ "$INVESTIGATOR_COMPLETE" = "true" ] || [ "$WORKFLOW_LABEL" = "workflow:ready-to-build" ]; then
308
+ PHASE="ready-to-build"
309
+ ACTION="Investigation complete. Invoke /work-on ${ISSUE_NUMBER} to continue to build phase."
310
+ elif has_annotation "FORGE:INVESTIGATOR" && [ "$INVESTIGATOR_COMPLETE" = "false" ]; then
311
+ PHASE="investigating"
312
+ ACTION="Investigation comment present but incomplete (no INVESTIGATION:COMPLETE sentinel). Delete partial comment and restart investigation."
313
+ elif [ "$WORKFLOW_LABEL" = "workflow:investigating" ]; then
314
+ PHASE="investigating"
315
+ ACTION="Investigation in progress. No FORGE:INVESTIGATOR comment yet."
316
+ else
317
+ PHASE="no-comments"
318
+ ACTION="No pipeline activity detected. Invoke /work-on ${ISSUE_NUMBER} to start investigation."
319
+ fi
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Phase 5: Classify lane (for informational output in phase_detail event)
323
+ # ---------------------------------------------------------------------------
324
+
325
+ LANE=""
326
+ BRANCH=""
327
+ LANE_JSON=""
328
+ if LANE_JSON=$("$CLASSIFY_LANE" "$ISSUE_NUMBER" "${GH_REPO_ARGS[@]}" --json 2>/dev/null); then
329
+ LANE=$(printf '%s' "$LANE_JSON" | jq -r '.lane // empty')
330
+ BRANCH=$(printf '%s' "$LANE_JSON" | jq -r '.branch // empty')
331
+ fi
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Phase 6: Emit events
335
+ # ---------------------------------------------------------------------------
336
+
337
+ ts_val=$(ts)
338
+
339
+ # Event 1: phase_detected
340
+ emit "{\"event\":\"phase_detected\",\"command\":\"$(json_str "${COMMAND}")\",\"issue\":${ISSUE_NUMBER},\"phase\":\"$(json_str "${PHASE}")\",\"ts\":\"${ts_val}\"}"
341
+
342
+ # Event 2: phase_detail
343
+ LABELS_STR=$(printf '%s' "$LABELS_JSON")
344
+ emit "{\"event\":\"phase_detail\",\"phase\":\"$(json_str "${PHASE}")\",\"lane\":\"$(json_str "${LANE}")\",\"branch\":\"$(json_str "${BRANCH}")\",\"labels\":${LABELS_STR}}"
345
+
346
+ # Event 3: action_required (only for non-terminal active phases)
347
+ if [ -n "$ACTION" ]; then
348
+ ts_val=$(ts)
349
+ ACTION_ESCAPED=$(json_str "${ACTION}")
350
+ emit "{\"event\":\"action_required\",\"phase\":\"$(json_str "${PHASE}")\",\"action\":\"${ACTION_ESCAPED}\",\"ts\":\"${ts_val}\"}"
351
+ fi
352
+
353
+ exit 0
@@ -0,0 +1,282 @@
1
+ # ForgeDock Cross-Repo Trigger — Workflow Template
2
+ #
3
+ # Installation
4
+ # ─────────────
5
+ # Copy this file into your PARENT repo at:
6
+ # .github/workflows/cross-repo-trigger.yml
7
+ #
8
+ # When a PR merges in this repo, the workflow reads forge.yaml to find satellite
9
+ # repos that have trigger_on_merge: true, checks whether touched files match each
10
+ # satellite's trigger_paths globs, and creates a structured investigation issue in
11
+ # every matching satellite repo.
12
+ #
13
+ # Token requirements
14
+ # ──────────────────
15
+ # GITHUB_TOKEN (provided automatically by GitHub Actions) only has write access
16
+ # to THIS repo. To create issues in satellite repos you need a Personal Access
17
+ # Token (PAT) with "issues: write" scope on each satellite repo:
18
+ #
19
+ # Repo Settings → Secrets and variables → Actions → New repository secret
20
+ # Name: FORGE_PAT
21
+ # Value: ghp_... (your PAT)
22
+ #
23
+ # The workflow uses FORGE_PAT when set; it falls back to GITHUB_TOKEN otherwise.
24
+ # For same-org repos you may not need a PAT if GITHUB_TOKEN already has cross-repo
25
+ # write access (org-level permission inheritance). Cross-org or private satellite
26
+ # repos always require a PAT.
27
+ #
28
+ # forge.yaml configuration
29
+ # ─────────────────────────
30
+ # Each satellite that should receive cross-repo triggers must set trigger_on_merge:
31
+ #
32
+ # repos:
33
+ # satellites:
34
+ # - prefix: "sdk"
35
+ # repo: "acme-org/acme-python-sdk"
36
+ # staging_branch: "main"
37
+ # local_path: "/home/youruser/projects/acme-python-sdk"
38
+ # trigger_on_merge: true
39
+ # trigger_paths:
40
+ # - "src/api/**"
41
+ # - "types/**"
42
+ # auto_run: false # reserved — see ForgeDock docs for auto_run setup
43
+ #
44
+ # Fields:
45
+ # trigger_on_merge — boolean; set true to enable triggering for this satellite
46
+ # trigger_paths — list of glob strings (GitHub Actions glob syntax); trigger
47
+ # fires only when at least one changed file matches; omit to
48
+ # trigger on every merge
49
+ # auto_run — boolean; reserved for future auto_run handler; currently no-op
50
+ #
51
+ # Limits and guards
52
+ # ─────────────────
53
+ # - Max 10 satellite issues created per merge event (rate limit guard)
54
+ # - Circular dependency guard: skips satellites whose repo matches this repo
55
+ # - Satellites with trigger_on_merge: false (or absent) are skipped
56
+ # - If forge.yaml is absent, the workflow exits cleanly with an informational log
57
+
58
+ name: ForgeDock Cross-Repo Trigger
59
+
60
+ on:
61
+ pull_request:
62
+ types: [closed]
63
+
64
+ jobs:
65
+ trigger:
66
+ name: Fire cross-repo satellite triggers
67
+ runs-on: ubuntu-latest
68
+
69
+ # Only run when the PR was actually merged (not just closed)
70
+ if: github.event.pull_request.merged == true
71
+
72
+ permissions:
73
+ contents: read # Required: read forge.yaml from checked-out repo
74
+ issues: write # Required: fallback if this repo is a self-referencing satellite
75
+
76
+ env:
77
+ # Use FORGE_PAT when set (cross-repo access); fall back to GITHUB_TOKEN.
78
+ # Assign via env so it is available to all steps without repeating the expression.
79
+ FORGE_TOKEN: ${{ secrets.FORGE_PAT || github.token }}
80
+
81
+ steps:
82
+ - name: Checkout repository
83
+ uses: actions/checkout@v4
84
+
85
+ - name: Check forge.yaml exists
86
+ id: forge_check
87
+ run: |
88
+ if [ -f "forge.yaml" ]; then
89
+ echo "exists=true" >> "$GITHUB_OUTPUT"
90
+ echo "forge.yaml found"
91
+ else
92
+ echo "exists=false" >> "$GITHUB_OUTPUT"
93
+ echo "forge.yaml not found — no satellite configuration to process. Exiting cleanly."
94
+ fi
95
+
96
+ - name: Parse forge.yaml and get changed files
97
+ id: forge_parse
98
+ if: steps.forge_check.outputs.exists == 'true'
99
+ env:
100
+ GH_TOKEN: ${{ env.FORGE_TOKEN }}
101
+ PR_NUMBER: ${{ github.event.pull_request.number }}
102
+ UPSTREAM_REPO: ${{ github.repository }}
103
+ run: |
104
+ # Get changed files from the merged PR
105
+ CHANGED_FILES_JSON=$(gh pr view "$PR_NUMBER" -R "$UPSTREAM_REPO" --json files --jq '[.files[].path]')
106
+ echo "Changed files: $CHANGED_FILES_JSON"
107
+ echo "changed_files_json=$CHANGED_FILES_JSON" >> "$GITHUB_OUTPUT"
108
+
109
+ # Parse satellite list from forge.yaml using Python3 (always available on ubuntu-latest)
110
+ SATELLITES_JSON=$(python3 -c "
111
+ import yaml, json, sys
112
+
113
+ try:
114
+ with open('forge.yaml', 'r') as f:
115
+ config = yaml.safe_load(f)
116
+ except Exception as e:
117
+ print(json.dumps([]), end='')
118
+ sys.exit(0)
119
+
120
+ repos = config.get('repos', {}) or {}
121
+ satellites = repos.get('satellites', []) or []
122
+
123
+ result = []
124
+ for sat in satellites:
125
+ if not isinstance(sat, dict):
126
+ continue
127
+ repo = sat.get('repo', '')
128
+ trigger_on_merge = sat.get('trigger_on_merge', False)
129
+ trigger_paths = sat.get('trigger_paths', []) or []
130
+ auto_run = sat.get('auto_run', False)
131
+ if not repo or not trigger_on_merge:
132
+ continue
133
+ result.append({
134
+ 'repo': repo,
135
+ 'trigger_paths': trigger_paths,
136
+ 'auto_run': bool(auto_run),
137
+ })
138
+
139
+ print(json.dumps(result), end='')
140
+ ")
141
+ echo "Satellites with trigger_on_merge=true: $SATELLITES_JSON"
142
+ echo "satellites_json=$SATELLITES_JSON" >> "$GITHUB_OUTPUT"
143
+
144
+ - name: Fire satellite triggers
145
+ if: steps.forge_check.outputs.exists == 'true'
146
+ env:
147
+ GH_TOKEN: ${{ env.FORGE_TOKEN }}
148
+ UPSTREAM_REPO: ${{ github.repository }}
149
+ PR_NUMBER: ${{ github.event.pull_request.number }}
150
+ MERGED_BRANCH: ${{ github.event.pull_request.head.ref }}
151
+ PR_URL: ${{ github.event.pull_request.html_url }}
152
+ PR_TITLE: ${{ github.event.pull_request.title }}
153
+ SATELLITES_JSON: ${{ steps.forge_parse.outputs.satellites_json }}
154
+ CHANGED_FILES_JSON: ${{ steps.forge_parse.outputs.changed_files_json }}
155
+ run: |
156
+ python3 -c "
157
+ import json, os, subprocess, sys, fnmatch
158
+
159
+ upstream_repo = os.environ['UPSTREAM_REPO']
160
+ pr_number = os.environ['PR_NUMBER']
161
+ merged_branch = os.environ['MERGED_BRANCH']
162
+ pr_url = os.environ['PR_URL']
163
+ pr_title = os.environ['PR_TITLE']
164
+ satellites_json = os.environ.get('SATELLITES_JSON', '[]')
165
+ changed_files_json = os.environ.get('CHANGED_FILES_JSON', '[]')
166
+
167
+ try:
168
+ satellites = json.loads(satellites_json) if satellites_json else []
169
+ except json.JSONDecodeError:
170
+ satellites = []
171
+
172
+ try:
173
+ changed_files = json.loads(changed_files_json) if changed_files_json else []
174
+ except json.JSONDecodeError:
175
+ changed_files = []
176
+
177
+ if not satellites:
178
+ print('No satellites with trigger_on_merge: true found in forge.yaml')
179
+ sys.exit(0)
180
+
181
+ if not changed_files:
182
+ print('No changed files found in merged PR — nothing to match against trigger_paths')
183
+ sys.exit(0)
184
+
185
+ def matches_glob(path, pattern):
186
+ if fnmatch.fnmatch(path, pattern):
187
+ return True
188
+ if '**' not in pattern:
189
+ return False
190
+ parts = pattern.split('**')
191
+ prefix = parts[0].rstrip('/')
192
+ suffix = parts[-1].lstrip('/')
193
+ if prefix and not path.startswith(prefix):
194
+ return False
195
+ if suffix and not (fnmatch.fnmatch(path, suffix) or '/' + suffix in path):
196
+ return False
197
+ return True
198
+
199
+ issues_created = 0
200
+ MAX_ISSUES = 10
201
+ NL = chr(10)
202
+
203
+ for sat in satellites:
204
+ if issues_created >= MAX_ISSUES:
205
+ print(f'Rate limit reached: {MAX_ISSUES} satellite issues created. Stopping.')
206
+ break
207
+
208
+ sat_repo = sat.get('repo', '')
209
+ trigger_paths = sat.get('trigger_paths', [])
210
+ auto_run = sat.get('auto_run', False)
211
+
212
+ if not sat_repo:
213
+ continue
214
+
215
+ # Circular dependency guard
216
+ if sat_repo == upstream_repo:
217
+ print(f'Skipping {sat_repo}: circular dependency guard (satellite == upstream repo)')
218
+ continue
219
+
220
+ # Glob matching against trigger_paths
221
+ if trigger_paths:
222
+ matched_files = [
223
+ f for f in changed_files
224
+ if any(matches_glob(f, p) for p in trigger_paths)
225
+ ]
226
+ if not matched_files:
227
+ print(f'Skipping {sat_repo}: no changed files match trigger_paths {trigger_paths}')
228
+ continue
229
+ else:
230
+ matched_files = list(changed_files)
231
+ print(f'{sat_repo}: no trigger_paths set — triggering on all {len(matched_files)} changed file(s)')
232
+
233
+ print(f'Creating issue in {sat_repo}: {len(matched_files)} matched file(s)')
234
+
235
+ matched_list = NL.join(f'- {f}' for f in matched_files)
236
+ auto_run_note = (
237
+ NL + '> **auto_run** is set for this satellite. '
238
+ 'Run /work-on on this issue number to begin automated investigation.' + NL
239
+ if auto_run else ''
240
+ )
241
+
242
+ issue_body = (
243
+ '<!-- FORGE:CROSS_REPO_TRIGGER -->' + NL +
244
+ '## Cross-Repo Trigger' + NL + NL +
245
+ f'A PR merged in **{upstream_repo}** touches paths relevant to this satellite repo.' + NL + NL +
246
+ '### Upstream Event' + NL +
247
+ f'- Upstream repo: {upstream_repo}' + NL +
248
+ f'- Merged PR: #{pr_number} ({pr_url})' + NL +
249
+ f'- PR title: {pr_title}' + NL +
250
+ f'- Merged branch: {merged_branch}' + NL + NL +
251
+ '### Matched Files' + NL +
252
+ f'These changed files matched this satellites trigger_paths:' + NL + NL +
253
+ matched_list + NL + NL +
254
+ '### Recommended Action' + NL +
255
+ 'Investigate whether these changes affect this satellite repos contracts, types, or integrations.' + NL + NL +
256
+ f'Review the diff at {pr_url} or run /work-on on this issue to begin automated investigation.' + NL +
257
+ auto_run_note + NL +
258
+ '### Context' + NL +
259
+ 'Created automatically by the ForgeDock cross-repo trigger workflow.' + NL +
260
+ 'See https://github.com/RapierCraftStudios/ForgeDock for documentation.' + NL
261
+ )
262
+
263
+ issue_title = f'feat(cross-repo): upstream merge in {upstream_repo} (PR #{pr_number}) — investigate impact'
264
+
265
+ result = subprocess.run(
266
+ ['gh', 'issue', 'create',
267
+ '-R', sat_repo,
268
+ '--title', issue_title,
269
+ '--body', issue_body],
270
+ capture_output=True, text=True
271
+ )
272
+
273
+ if result.returncode == 0:
274
+ issue_url = result.stdout.strip()
275
+ print(f'Created issue in {sat_repo}: {issue_url}')
276
+ issues_created += 1
277
+ else:
278
+ print(f'ERROR creating issue in {sat_repo}: {result.stderr.strip()}')
279
+ print('Hint: Ensure FORGE_PAT has issues:write access to this satellite repo.')
280
+
281
+ print(f'Done: {issues_created} satellite issue(s) created.')
282
+ "