-
Notifications
You must be signed in to change notification settings - Fork 7
3259 lines (2889 loc) · 174 KB
/
Copy pathcode-documentation.yml
File metadata and controls
3259 lines (2889 loc) · 174 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
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Flamingo Code Documentation
# =============================================================================
# Installed into a target repository by the multi-platform hub ("Setup
# Workflow"). The hub dispatches it to generate this repository's
# documentation and open a pull request with the result.
#
# Jobs
# code-graph Deterministic code graph (no model call). Runs on pushes to
# the default branch, on the hub's `flamingo-code-graph`
# re-dispatch, and on a manual run with graph_only=true.
# doc-pipeline The documentation run, dispatched by the hub:
# Stage 0 code graph (same build as the code-graph job)
# Stage 1 inline docs, one hidden .md beside each source file
# Stage 2 reference docs: CodeWiki, or the Claude
# architecture analysis where CodeWiki cannot parse
# the primary language
# Stage 3 tutorials (getting started, development)
# Stage 4 repository docs: README, CONTRIBUTING, docs index
#
# Stages 2 (Claude), 3 and 4 follow the code reviewer's agentic pattern
# (templates/scripts/code-documentation-lib.mjs): the hub serves this
# repository's settings, the script packs the material, ONE hub call writes
# ONE document (forced `emit_document`; the hub's read tools once the
# repository's visibility is resolved), a deterministic gate checks every
# repository path it names, and the script, never the model, picks where each
# document is written.
#
# Fleet contracts (renaming any of these needs a fleet-wide re-push): the
# installed path .github/workflows/code-documentation.yml (the setup PR
# hard-deletes the old doc-orchestrator.yml), the repository_dispatch type
# `code-documentation`, the secrets below, and the script file names the hub serves.
#
# Repository secrets (Settings > Secrets and variables > Actions)
# FLAMINGO_HUB_SECRET Required. Authenticates every hub call.
# ANTHROPIC_API_KEY CodeWiki (stage 2) only; every other stage calls
# Claude through the hub.
# OPENAI_API_KEY CodeWiki (stage 2).
# YOUTUBE_API_KEY Optional. YouTube embeds in stages 3 and 4.
name: 🦩 Flamingo Code Documentation
on:
# Push trigger — two things ride it. It registers the workflow with GitHub
# Actions (required for the workflow_dispatch API), and on the repository's
# DEFAULT branch it runs the `code-graph` job below, which re-indexes the
# code graph the hub serves to the code reviewer and to the documentation
# stages. The documentation pipeline itself NEVER runs on push (see its
# `if:`). Documentation and markdown are ignored on purpose: a docs PR
# merging must not rebuild a graph that only source files can change.
push:
paths-ignore:
- 'docs/**'
- '**.md'
# Multi-repo change sets, LIVE: every pull request event (a `Depends-On:` line
# added, a push, a merge) asks the hub to refresh its change sets at once, and
# the same run builds the PR's overlay when the hub says it is a member whose
# overlay is missing or stale. ONLY the code-graph job runs on this event (see
# both jobs' `if:`); no path filter, because a description edit is the event.
pull_request:
types: [opened, reopened, edited, synchronize, ready_for_review, closed]
repository_dispatch:
# `code-documentation` (CODE_DOCUMENTATION_DISPATCH_EVENT_TYPE) runs the
# documentation pipeline. `flamingo-code-graph`
# (CODE_GRAPH_DISPATCH_EVENT_TYPE in lib/config/code-graph-workflow.ts)
# runs ONLY the graph job; the hub's reconcile job sends it when a
# repository's graph is missing or stale.
types: [code-documentation, flamingo-code-graph]
workflow_dispatch:
# ═══════════════════════════════════════════════════════════════════════════
# GENERATED FROM SINGLE SOURCE OF TRUTH: lib/config/code-documentation-params.ts
# This section is auto-generated at runtime when creating workflow PRs
# ═══════════════════════════════════════════════════════════════════════════
inputs:
# Individual parameters
run_id:
description: 'Unique execution ID'
required: true
repo_id:
description: 'Repository ID from database'
required: true
hub_base_url:
description: 'Hub base URL (e.g., https://product-hub.flamingo.so)'
required: true
stages:
description: 'Pipeline stages to execute'
required: true
dependencies:
description: 'Comma-separated dependency repos'
required: false
default: ''
source_branch:
description: 'Branch to analyze code from'
required: true
source_files_limit:
description: 'Max source files to process (0 = unlimited)'
required: true
claude_model:
description: 'Claude model ID for Stage 1/3/4 + Stage 2 Claude-arch fallback (SSOT from hub)'
required: true
codewiki_config:
description: 'Complete CodeWiki configuration (per-phase models, engine, stages, depth)'
required: true
output_paths:
description: 'Output paths configuration'
required: true
timeout:
description: 'Timeout in hours'
required: true
youtube_config:
description: 'YouTube integration configuration (channels only - API key in secrets)'
required: true
readme_config:
description: 'README logo configuration'
required: true
custom_repo_instructions:
description: 'Custom AI instructions'
required: false
default: ''
external_repos:
description: 'External repos JSON'
required: false
default: '[]'
stage_count:
description: 'Total number of pipeline stages'
required: false
default: '4'
graph_only:
description: 'true = run only the code-graph job (no documentation run)'
required: false
default: 'false'
# ═══════════════════════════════════════════════════════════════════════════
# END GENERATED SECTION
# ═══════════════════════════════════════════════════════════════════════════
env:
# SECURITY: Only NON-SENSITIVE variables in job-level env
# Secrets are passed per-step to avoid exposure in job setup logs
# Run configuration (non-sensitive)
RUN_ID: ${{ github.event.client_payload.run_id || github.event.inputs.run_id || github.run_id }}
REPO_ID: ${{ github.event.client_payload.repo_id || github.event.inputs.repo_id || '' }}
# A push event carries no payload, so the graph job falls back to the org
# Actions variable — the same fallback the code-review workflow uses.
HUB_BASE_URL: ${{ github.event.client_payload.hub_base_url || github.event.inputs.hub_base_url || vars.FLAMINGO_HUB_BASE_URL || '' }}
# 'true' = run only the code-graph job (a manual workflow_dispatch; the hub's
# own documentation dispatches always send 'false').
GRAPH_ONLY: ${{ github.event.client_payload.graph_only || github.event.inputs.graph_only || 'false' }}
# No literal fallback: the stage list lives in CODE_DOCUMENTATION_STAGES on the
# hub and is lifted into the payload per repo. A literal here would silently
# restore all four stages on a payload gap, and "Remove docs this run
# regenerates" would already have wiped the docs tree: the run would delete
# docs and regenerate nothing.
STAGES: ${{ github.event.client_payload.stages || github.event.inputs.stages || '' }}
DEPENDENCIES: ${{ github.event.client_payload.dependencies || github.event.inputs.dependencies || '' }}
STAGE_COUNT: ${{ github.event.client_payload.stage_count || github.event.inputs.stage_count || '4' }}
# Branch to checkout for code analysis (github_branch from repo config)
SOURCE_BRANCH: ${{ github.event.client_payload.source_branch || github.event.inputs.source_branch || 'main' }}
# Debug/testing: limit total source files to analyze (0=unlimited)
# Files beyond this limit are DELETED - all stages then process remaining files
SOURCE_FILES_LIMIT: ${{ github.event.client_payload.source_files_limit || github.event.inputs.source_files_limit || '0' }}
# Claude model SSOT: the hub's CODE_DOCUMENTATION_DEFAULT_MODEL
# (lib/constants/ai-models.ts). The stage 1, 3 and 4 generators and the
# stage 2 Claude analysis all read this variable; no shipped script holds a
# literal model id, and every one throws if CLAUDE_MODEL is empty. Re-run
# "Setup Workflow" after bumping the hub-side constant to propagate it.
# There is deliberately NO companion request-shape variable: every stage
# except CodeWiki calls Claude through the hub (/api/ci/claude), which
# resolves the model's request shape itself.
CLAUDE_MODEL: ${{ github.event.client_payload.claude_model || github.event.inputs.claude_model || '' }}
# =============================================================================
# JSON-grouped parameters to stay under GitHub Actions 25-parameter limit
# These are parsed early in the workflow to extract individual values
# =============================================================================
# NOTE: these fall back to EMPTY, not '{}'. An empty-object default made the
# `[ -z ... ]` presence checks below unreachable, so a missing payload silently
# produced `null` for every jq lookup and propagated as `--cluster-model null`.
# The hub always sends a complete, deep-merged blob (buildPayloadFromRepo).
CODEWIKI_CONFIG_JSON: ${{ github.event.client_payload.codewiki_config || github.event.inputs.codewiki_config || '' }}
OUTPUT_PATHS_JSON: ${{ github.event.client_payload.output_paths || github.event.inputs.output_paths || '' }}
# Stage timeouts (in hours) — single source of truth, downstream steps reference
# `env.STAGE_TIMEOUT_HOURS` directly. Stage 4 is the exception (1h vs 24h cap).
STAGE_TIMEOUT_HOURS: ${{ github.event.client_payload.timeout || github.event.inputs.timeout || '24' }}
# Aliases preserved for downstream step env: keys (they reference these names
# by string). All resolve to the same single source.
STAGE1_TIMEOUT_HOURS: ${{ github.event.client_payload.timeout || github.event.inputs.timeout || '24' }}
# Stage 1 incremental push: commit + push the PR branch every N generated inline
# docs. A 24h stage that gets cancelled used to lose ALL of its work because the
# only commit happened after the generator returned. Bound the loss to N files.
STAGE1_PUSH_INTERVAL: ${{ github.event.client_payload.stage1_push_interval || '100' }}
STAGE2_TIMEOUT_HOURS: ${{ github.event.client_payload.timeout || github.event.inputs.timeout || '24' }}
STAGE3_TIMEOUT_HOURS: ${{ github.event.client_payload.timeout || github.event.inputs.timeout || '24' }}
# Stage 4: Repository Documentation
TEMPLATE_REPO: ${{ github.event.client_payload.template_repo || 'flamingo-stack/openframe-oss-tenant' }}
TEMPLATE_BRANCH: ${{ github.event.client_payload.template_branch || 'main' }}
STAGE4_TIMEOUT_HOURS: ${{ github.event.client_payload.stage4_timeout || '1' }}
# YouTube Integration (Stage 3 + Stage 4) - JSONB configuration (API key from secrets)
YOUTUBE_CONFIG_JSON: ${{ github.event.client_payload.youtube_config || github.event.inputs.youtube_config || '' }}
# README Configuration - JSONB configuration for logo branding
README_CONFIG_JSON: ${{ github.event.client_payload.readme_config || github.event.inputs.readme_config || '' }}
# Custom AI Instructions (All Stages) - Repository-specific instructions for AI generation
CUSTOM_REPO_INSTRUCTIONS: ${{ github.event.client_payload.custom_repo_instructions || github.event.inputs.custom_repo_instructions || '' }}
# External Repositories - JSON array of external repo configurations
EXTERNAL_REPOS: ${{ github.event.client_payload.external_repos || github.event.inputs.external_repos || '[]' }}
# ========================================================================
# Repository Context - CRITICAL for preventing AI URL hallucinations
# These values are passed to ALL AI prompts to ensure correct GitHub URLs
# ========================================================================
GITHUB_REPOSITORY: ${{ github.repository }} # e.g., "flamingo-stack/openframe-oss-tenant"
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} # e.g., "flamingo-stack"
GITHUB_SERVER_URL: ${{ github.server_url }} # e.g., "https://github.com"
# Analysis Exclusions - Complete array of glob patterns to exclude from repository analysis
# (build artifacts, dependencies, the hub's own checkout, cloned dependency repos)
EXCLUDED_PATHS: '**/node_modules/**,**/.git/**,**/target/**,**/dist/**,**/build/**,**/.next/**,**/out/**,**/coverage/**,**/vendor/**,**/.yalc/**,**/.turbo/**,**/.gradle/**,**/__pycache__/**,**/.terraform/**,**/.venv/**,**/venv/**,**/multi-platform-hub/**,**/deps-*/**'
# The documentation pull request's branch prefix (CODE_DOCUMENTATION_BRANCH_PREFIX, written by npm run workflows:sync)
DOCS_BRANCH_PREFIX: 'code-documentation/'
README_LOGO_ALT: 'OpenFrame Logo'
jobs:
# ===========================================================================
# CODE GRAPH: deterministic, no model call. Tags every public symbol,
# import and manifest of the checkout (code-graph-build.mjs) and uploads the
# result to the hub, which promotes a default-branch snapshot to `live` and
# serves it to the code reviewer (consumers of a symbol a PR removes) and to
# the documentation stages (the derived ecosystem.md). Runs on every push to
# the default branch, on the hub's `flamingo-code-graph` re-dispatch, and on
# a manual workflow_dispatch with graph_only=true. There is no webhook
# callback: the upload IS the report. The same build also runs as stage 0 of
# a full documentation run (inside doc-pipeline, below).
# ===========================================================================
code-graph:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
# THREE groups, so no two kinds of run ever cancel each other:
# - a `pull_request` event runs in `-pr-<n>`, and NEVER cancels a running one
# (`cancel-in-progress` is false for it): a newer event QUEUES behind the run
# in progress (GitHub keeps one pending run per group, replacing an older
# pending one, so the newest event still runs). Cancelling would kill an
# overlay build the hub already recorded as this PR's own (a description
# edit or the merge seconds after a push), and nothing would rebuild that
# head until the retry window passed;
# - an OVERLAY build the hub's `code-change-sets` job dispatches
# (overlay_pr / overlay_head_sha in the payload) runs in `-overlay-<n>`: a
# newer dispatch for the same PR supersedes the older one;
# - everything else (the default branch's rebuild) keeps the repository group.
# A dispatched build and the PR's own run may build the same head at once;
# that is harmless: the hub upserts an overlay on (repo, pr, head, generator).
# A bot's own description edit (the hub writing its block) is excluded by the
# `if:` below, but a job may join its concurrency group before that `if:` is
# evaluated: it gets a group of its own (`-bot-<run id>`), so it can never
# cancel the PR's run that is building the overlay the same refresh asked for.
concurrency:
group: flamingo-code-graph-${{ github.repository }}${{ github.event.client_payload.overlay_pr && format('-overlay-{0}', github.event.client_payload.overlay_pr) || github.event.pull_request.number && format('-pr-{0}', github.event.pull_request.number) || '' }}${{ (github.event.action == 'edited' && github.event.sender.type == 'Bot') && format('-bot-{0}', github.run_id) || '' }}
cancel-in-progress: ${{ github.event_name != 'pull_request' }}
if: >-
(github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch))
|| github.event.action == 'flamingo-code-graph'
|| (github.event_name == 'workflow_dispatch' && github.event.inputs.graph_only == 'true')
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
&& !startsWith(github.event.pull_request.head.ref, 'ai-fix/')
&& !startsWith(github.event.pull_request.head.ref, 'setup/')
&& !startsWith(github.event.pull_request.head.ref, 'code-documentation/')
&& !(github.event.action == 'edited' && github.event.sender.type == 'Bot'))
# The overlay coordinates, spelled ONCE: the hub's dispatch payload, or this PR's own event (the steps
# below build only after the live refresh answered `build`). Both empty on a default-branch rebuild.
env:
OVERLAY_PR: ${{ github.event.client_payload.overlay_pr || github.event.pull_request.number || '' }}
OVERLAY_HEAD_SHA: ${{ github.event.client_payload.overlay_head_sha || github.event.pull_request.head.sha || '' }}
steps:
# Fail LOUD, not silent: a push on a repo whose org never set
# FLAMINGO_HUB_BASE_URL would otherwise curl an empty origin and die with
# an unrelated error. Also normalizes a trailing slash ONCE.
- name: Validate configuration
id: config
# A pull_request event must never turn a PR check red: the refresh is advisory
# (the hub's webhook path and schedule are the net), so a missing hub URL or
# secret on a PR only skips it.
continue-on-error: ${{ github.event_name == 'pull_request' }}
run: |
if [ -z "$HUB_BASE_URL" ]; then
echo "::error title=Hub URL missing::HUB_BASE_URL is empty. Set the organization Actions variable FLAMINGO_HUB_BASE_URL, or pass hub_base_url in the dispatch payload."
exit 1
fi
echo "HUB_BASE_URL=${HUB_BASE_URL%/}" >> "$GITHUB_ENV"
echo "Hub: ${HUB_BASE_URL%/}"
# The shared script bootstrap (byte-mirrored from workflow-scripts-bootstrap.ts,
# asserted by the build gate). BOTH graph scripts are downloaded: the builder
# imports ./code-graph-lib.mjs from its own directory.
- name: Download graph scripts
id: scripts
if: github.event_name != 'pull_request' || steps.config.outcome == 'success'
continue-on-error: ${{ github.event_name == 'pull_request' }}
env:
WEBHOOK_SECRET: ${{ secrets.FLAMINGO_HUB_SECRET }}
run: |
# Function to download and verify script
SCRIPT_MANIFEST=/tmp/flamingo-script-manifest.json
# WEBHOOK_SECRET reaches curl through a 0600 config file, never argv — see
# curlAuthPreamble, which always traps the removal.
CURL_CFG=$(mktemp) && chmod 600 "$CURL_CFG"
trap 'rm -f "$CURL_CFG"' EXIT
printf 'header = "Authorization: Bearer %s"\n' "$WEBHOOK_SECRET" > "$CURL_CFG"
# The scripts surface. load_script_manifest pins SCRIPTS_BASE_URL to it.
CI_SCRIPTS_URL="${HUB_BASE_URL%/}/api/ci/scripts"
# _try_manifest <base> — 0 loaded, 1 no manifest surface there, 2 fatal.
# The manifest is asked for ONE group: its keys are the files to download.
_try_manifest() {
local base="$1" code
code=$(curl -sS -w '%{http_code}' -o "$SCRIPT_MANIFEST" \
-K "$CURL_CFG" \
"$base/manifest.json?group=$SCRIPT_GROUP") || code="000"
if [ "$code" = "404" ]; then rm -f "$SCRIPT_MANIFEST"; return 1; fi
if [ "$code" != "200" ]; then
echo "❌ manifest request to $base failed (HTTP $code)"
rm -f "$SCRIPT_MANIFEST"
return 2
fi
# The digests are the TOP-LEVEL object. successResponse is the standard
# emitter but it does NOT add a wrapper — it is NextResponse.json(data)
# plus the no-store header — so there is no .data to reach through.
# A 200 that is not a manifest is how a hub which does not serve this path
# answers (the proxy rewrites unknown routes and returns HTML), so it
# means "wrong surface", not "corrupt".
if ! jq -e 'type == "object" and length > 0 and (to_entries | all(.value | type == "string"))' "$SCRIPT_MANIFEST" >/dev/null 2>&1; then
rm -f "$SCRIPT_MANIFEST"
return 1
fi
return 0
}
# load_script_manifest <group>
load_script_manifest() {
SCRIPT_GROUP="$1"
# "cmd; rc=$?" dies under the set -euo pipefail these steps run with —
# errexit fires before rc is read and the step ends with NO output. And
# "if ! cmd; then rc=$?" is worse: inside the branch $? is the status of
# the NEGATION (0), so every failure reads as success. "|| rc=$?" is the
# one form that both suppresses errexit and preserves the real code.
local rc=0
_try_manifest "$CI_SCRIPTS_URL" || rc=$?
if [ "$rc" = "0" ]; then
SCRIPTS_BASE_URL="$CI_SCRIPTS_URL"
echo "✅ script manifest loaded ($(jq -r 'length' "$SCRIPT_MANIFEST") scripts)"
return 0
fi
if [ "$rc" = "2" ]; then exit 1; fi
# No manifest on the scripts surface: a hub older than the manifest
# itself. The manifest is the file list, so there is nothing to download.
echo "❌ no script manifest on this hub ($HUB_BASE_URL): it cannot name the $SCRIPT_GROUP scripts. Redeploy the hub."
exit 1
}
# download_script_group <group> — the hub names the files, this workflow
# names only the group. Downloads every script of the group, in served order.
download_script_group() {
load_script_manifest "$1"
local name
# The loop runs in THIS shell (no pipe), so a failed download exits the step.
while IFS= read -r name; do
download_and_verify "$name"
done < <(jq -r 'keys_unsorted[]' "$SCRIPT_MANIFEST")
}
download_and_verify() {
local script_name="$1"
local output_path="/tmp/$script_name"
local expected_hash
expected_hash=$(jq -r --arg n "$script_name" '.[$n] // empty' "$SCRIPT_MANIFEST")
if [ -z "$expected_hash" ]; then
echo "❌ $script_name is not in the server's script manifest!"
echo " The hub serves no such script, or it failed to read on the server."
exit 1
fi
if ! printf '%s' "$expected_hash" | grep -Eq '^[0-9a-f]{64}$'; then
echo "❌ the manifest entry for $script_name is not a SHA-256 digest — refusing to run it."
exit 1
fi
curl -fsSL "$SCRIPTS_BASE_URL/$script_name" \
-K "$CURL_CFG" \
-o "$output_path"
local actual_hash=$(shasum -a 256 "$output_path" | cut -d' ' -f1)
if [ "$actual_hash" != "$expected_hash" ]; then
echo "❌ HASH MISMATCH for $script_name!"
echo " Expected: $expected_hash"
echo " Actual: $actual_hash"
echo " The download was corrupted in transit — both values come from the same deployment."
exit 1
fi
# Make shell scripts executable
if [[ "$script_name" == *.sh ]]; then
chmod +x "$output_path"
fi
echo "✅ $script_name verified (hash: ${actual_hash:0:16}...)"
}
# Digests AND the file list come from the deployment serving the bytes,
# not from this file: the step names a group (SCRIPT_GROUPS in the hub's
# lib/config/ci-script-catalog.ts) and downloads what the hub lists for it.
download_script_group "code-graph"
# LIVE change sets (a `pull_request` event): ask the hub to refresh NOW and
# whether this run builds the PR's overlay (`build`). Never fails the run.
# A fork has no secrets and is excluded by the job's `if:`, as are the hub
# tools' own branches (TOOL_BRANCH_PREFIXES); a bot's own description edit
# (the hub writing its block) is excluded too, or every hub write would
# start another run.
- name: Refresh the change set
id: refresh
if: github.event_name == 'pull_request' && steps.scripts.outcome == 'success'
# A hub that cannot answer never fails the pull request's check: the schedule links the set.
continue-on-error: true
env:
WEBHOOK_SECRET: ${{ secrets.FLAMINGO_HUB_SECRET }}
GITHUB_REPOSITORY: ${{ github.repository }}
CODE_GRAPH_REFRESH_PR: ${{ github.event.pull_request.number }}
# The head of THIS event: the hub answers `build` only while it is still the PR's live head.
CODE_GRAPH_REFRESH_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
# The event's action: the hub runs its job only for one that can change a set (CHANGE_SET_REFRESH_ACTIONS).
CODE_GRAPH_REFRESH_ACTION: ${{ github.event.action }}
# The event's sender type: the hub refuses a bot's own description edit too, not only this job's `if:`.
CODE_GRAPH_REFRESH_SENDER_TYPE: ${{ github.event.sender.type }}
run: node /tmp/code-graph-build.mjs
# FULL history, blobless. `collectFileFacts` derives per-file ownership
# (last commit, recent commits, commits in the window) from one
# `git log --no-merges --no-renames` walk, which a depth-1 checkout
# cannot answer — it would report every file as owned by one commit.
# `filter: blob:none` keeps the clone cheap: the walk reads commit
# metadata and name-only paths, never file contents, which is also why
# the walk passes `--no-renames` (rename detection would fetch blobs).
# A shallow checkout still degrades gracefully: ownership is omitted and
# `coverage.ownership` is false rather than the job failing.
# In overlay mode the checkout is the PR HEAD, with every blob: the
# overlay diffs it against the live snapshot commit with rename
# detection, which reads contents a blob:none clone cannot fetch here.
#
# The four build steps below share one `if:` (a PR event builds only when the refresh answered
# `build`) and one `continue-on-error` (a pull request's check never goes red on the overlay: the
# graph lane and the schedule rebuild it). Actions has no step group, so each step states both.
- name: Check out repository
if: github.event_name != 'pull_request' || steps.refresh.outputs.build == 'true'
continue-on-error: ${{ github.event_name == 'pull_request' }}
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
ref: ${{ env.OVERLAY_HEAD_SHA }}
fetch-depth: 0
# `!x && 'blob:none' || ''` — never `x && '' || …`: '' is falsy in an expression, so that form always yields blob:none.
filter: ${{ !env.OVERLAY_PR && 'blob:none' || '' }}
persist-credentials: false
- name: Set up Node.js
if: github.event_name != 'pull_request' || steps.refresh.outputs.build == 'true'
continue-on-error: ${{ github.event_name == 'pull_request' }}
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
# v5+ caches automatically when it finds a package manager; this job never did.
package-manager-cache: false
# The command is CODE_GRAPH_INSTALL_COMMAND (lib/config/code-graph-workflow.ts),
# the one spelling both workflows use: pinned wasm tree-sitter + grammars +
# yaml into an isolated tree under RUNNER_TEMP, exported as CODE_GRAPH_DEPS_DIR.
- name: Install graph dependencies
if: github.event_name != 'pull_request' || steps.refresh.outputs.build == 'true'
continue-on-error: ${{ github.event_name == 'pull_request' }}
run: mkdir -p "$RUNNER_TEMP/code-graph-deps" && cd "$RUNNER_TEMP/code-graph-deps" && printf '%s' '{"name":"code-graph-deps","version":"1.0.0","private":true,"dependencies":{"web-tree-sitter":"0.27.0","@vscode/tree-sitter-wasm":"0.3.1","yaml":"2.9.1"}}' > package.json && printf '%s' '{"name":"code-graph-deps","version":"1.0.0","lockfileVersion":3,"requires":true,"packages":{"":{"name":"code-graph-deps","version":"1.0.0","dependencies":{"web-tree-sitter":"0.27.0","@vscode/tree-sitter-wasm":"0.3.1","yaml":"2.9.1"}},"node_modules/web-tree-sitter":{"version":"0.27.0","resolved":"https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.27.0.tgz","integrity":"sha512-XK08gj6RwTMQatAG7uVRP8MunqotL/XC19vHgkSPKmELgbGPBj4ECvB8haHOUnyj6ls2B8t42UTro14zxGgAHg=="},"node_modules/@vscode/tree-sitter-wasm":{"version":"0.3.1","resolved":"https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.3.1.tgz","integrity":"sha512-RJFoomET6FajjG511fmQxeBQfU6M24a0aFZPqpid+ttIxanWf1VGytBG0UmsGjt07qmIPJS8U31D+aecuCucsQ=="},"node_modules/yaml":{"version":"2.9.1","resolved":"https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz","integrity":"sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="}}}' > package-lock.json && npm ci --ignore-scripts --no-audit --no-fund && echo "CODE_GRAPH_DEPS_DIR=$RUNNER_TEMP/code-graph-deps" >> "$GITHUB_ENV" || { echo "::warning::graph dependencies failed their lockfile-enforced install; continuing without them"; rm -rf "$RUNNER_TEMP/code-graph-deps"; exit 1; }
# Overlay mode posts the PR's overlay INSTEAD of a snapshot (code-graph-build.mjs `overlayMain`).
- name: Build and upload the code graph
id: graph
if: github.event_name != 'pull_request' || steps.refresh.outputs.build == 'true'
continue-on-error: ${{ github.event_name == 'pull_request' }}
env:
WEBHOOK_SECRET: ${{ secrets.FLAMINGO_HUB_SECRET }}
CODE_GRAPH_DEPS_DIR: ${{ env.CODE_GRAPH_DEPS_DIR }}
GITHUB_REPOSITORY: ${{ github.repository }}
# Overlay mode: the job's overlay coordinates (empty outside it).
CODE_GRAPH_OVERLAY_PR: ${{ env.OVERLAY_PR }}
CODE_GRAPH_OVERLAY_HEAD_SHA: ${{ env.OVERLAY_HEAD_SHA }}
# An overlay run names a NON-default branch, so a hub or builder that predates overlay mode lands
# the PR head as a `branch` snapshot, never as the repository's live graph (the e2e run found an
# older builder promoting an unmerged PR's head live). Empty outside overlay mode.
CODE_GRAPH_BRANCH: ${{ env.OVERLAY_PR && format('overlay/pr-{0}', env.OVERLAY_PR) || '' }}
run: node /tmp/code-graph-build.mjs
# ===========================================================================
# DOCUMENTATION PIPELINE: stages 0 to 4, one pull request per run. Every
# stage commits and pushes its own output as it finishes, so a timeout or a
# cancellation loses at most the stage in flight.
# ===========================================================================
doc-pipeline:
runs-on: ubuntu-latest
timeout-minutes: 720 # 12 hours for large repositories with many files
# contents: push the docs branch. pull-requests: open and update the pull
# request. issues: `gh label create` for the documentation / automated /
# in-progress labels; without it a repository that lacks them answers 403
# on the label and then 422 on `gh pr create --label`.
permissions:
contents: write
pull-requests: write
issues: write
# Never on push (a push registers the workflow and runs the code-graph job
# only), never on the graph-only re-dispatch, never on a graph-only manual
# run. This is what lets workflow_dispatch API calls work on feature branches.
if: github.event_name != 'push' && github.event_name != 'pull_request' && github.event.action != 'flamingo-code-graph' && github.event.inputs.graph_only != 'true'
steps:
# =========================================================================
# REPORT CAPABILITY FIRST (shared failure-net standard with the code-review
# workflow): workflow-helpers.sh — which carries send_webhook and the
# report/stage-callback helpers — downloads in its OWN step before anything
# else, so a failure in the main script download below can still be pinged
# and reported home instead of leaving a phantom pending/running row.
# =========================================================================
- name: Download report helpers
env:
WEBHOOK_SECRET: ${{ secrets.FLAMINGO_HUB_SECRET }}
run: |
# Function to download and verify script
SCRIPT_MANIFEST=/tmp/flamingo-script-manifest.json
# WEBHOOK_SECRET reaches curl through a 0600 config file, never argv — see
# curlAuthPreamble, which always traps the removal.
CURL_CFG=$(mktemp) && chmod 600 "$CURL_CFG"
trap 'rm -f "$CURL_CFG"' EXIT
printf 'header = "Authorization: Bearer %s"\n' "$WEBHOOK_SECRET" > "$CURL_CFG"
# The scripts surface. load_script_manifest pins SCRIPTS_BASE_URL to it.
CI_SCRIPTS_URL="${HUB_BASE_URL%/}/api/ci/scripts"
# _try_manifest <base> — 0 loaded, 1 no manifest surface there, 2 fatal.
# The manifest is asked for ONE group: its keys are the files to download.
_try_manifest() {
local base="$1" code
code=$(curl -sS -w '%{http_code}' -o "$SCRIPT_MANIFEST" \
-K "$CURL_CFG" \
"$base/manifest.json?group=$SCRIPT_GROUP") || code="000"
if [ "$code" = "404" ]; then rm -f "$SCRIPT_MANIFEST"; return 1; fi
if [ "$code" != "200" ]; then
echo "❌ manifest request to $base failed (HTTP $code)"
rm -f "$SCRIPT_MANIFEST"
return 2
fi
# The digests are the TOP-LEVEL object. successResponse is the standard
# emitter but it does NOT add a wrapper — it is NextResponse.json(data)
# plus the no-store header — so there is no .data to reach through.
# A 200 that is not a manifest is how a hub which does not serve this path
# answers (the proxy rewrites unknown routes and returns HTML), so it
# means "wrong surface", not "corrupt".
if ! jq -e 'type == "object" and length > 0 and (to_entries | all(.value | type == "string"))' "$SCRIPT_MANIFEST" >/dev/null 2>&1; then
rm -f "$SCRIPT_MANIFEST"
return 1
fi
return 0
}
# load_script_manifest <group>
load_script_manifest() {
SCRIPT_GROUP="$1"
# "cmd; rc=$?" dies under the set -euo pipefail these steps run with —
# errexit fires before rc is read and the step ends with NO output. And
# "if ! cmd; then rc=$?" is worse: inside the branch $? is the status of
# the NEGATION (0), so every failure reads as success. "|| rc=$?" is the
# one form that both suppresses errexit and preserves the real code.
local rc=0
_try_manifest "$CI_SCRIPTS_URL" || rc=$?
if [ "$rc" = "0" ]; then
SCRIPTS_BASE_URL="$CI_SCRIPTS_URL"
echo "✅ script manifest loaded ($(jq -r 'length' "$SCRIPT_MANIFEST") scripts)"
return 0
fi
if [ "$rc" = "2" ]; then exit 1; fi
# No manifest on the scripts surface: a hub older than the manifest
# itself. The manifest is the file list, so there is nothing to download.
echo "❌ no script manifest on this hub ($HUB_BASE_URL): it cannot name the $SCRIPT_GROUP scripts. Redeploy the hub."
exit 1
}
# download_script_group <group> — the hub names the files, this workflow
# names only the group. Downloads every script of the group, in served order.
download_script_group() {
load_script_manifest "$1"
local name
# The loop runs in THIS shell (no pipe), so a failed download exits the step.
while IFS= read -r name; do
download_and_verify "$name"
done < <(jq -r 'keys_unsorted[]' "$SCRIPT_MANIFEST")
}
download_and_verify() {
local script_name="$1"
local output_path="/tmp/$script_name"
local expected_hash
expected_hash=$(jq -r --arg n "$script_name" '.[$n] // empty' "$SCRIPT_MANIFEST")
if [ -z "$expected_hash" ]; then
echo "❌ $script_name is not in the server's script manifest!"
echo " The hub serves no such script, or it failed to read on the server."
exit 1
fi
if ! printf '%s' "$expected_hash" | grep -Eq '^[0-9a-f]{64}$'; then
echo "❌ the manifest entry for $script_name is not a SHA-256 digest — refusing to run it."
exit 1
fi
curl -fsSL "$SCRIPTS_BASE_URL/$script_name" \
-K "$CURL_CFG" \
-o "$output_path"
local actual_hash=$(shasum -a 256 "$output_path" | cut -d' ' -f1)
if [ "$actual_hash" != "$expected_hash" ]; then
echo "❌ HASH MISMATCH for $script_name!"
echo " Expected: $expected_hash"
echo " Actual: $actual_hash"
echo " The download was corrupted in transit — both values come from the same deployment."
exit 1
fi
# Make shell scripts executable
if [[ "$script_name" == *.sh ]]; then
chmod +x "$output_path"
fi
echo "✅ $script_name verified (hash: ${actual_hash:0:16}...)"
}
# Digests AND the file list come from the deployment serving the bytes, not from this file.
download_script_group "doc-helpers"
# =========================================================================
# REPORT RUN STARTED: the early "the workflow actually started" ping.
# Deliberately BEFORE the main script download: it stamps workflow_run_id +
# status 'running' on the hub's run row, which is what lets the hub's tiered
# reaper tell "dispatch accepted but nothing ran" (never pinged, failed
# fast) from "started and then crashed" (pinged, longer deadline).
# =========================================================================
- name: Report run started
if: env.HUB_BASE_URL != ''
continue-on-error: true
env:
WEBHOOK_SECRET: ${{ secrets.FLAMINGO_HUB_SECRET }}
run: |
source /tmp/workflow-helpers.sh
CALLBACK_URL="${HUB_BASE_URL}/api/code-documentation/webhook"
echo "Reporting run start to $CALLBACK_URL"
PAYLOAD="{
\"run_id\": \"$RUN_ID\",
\"repo_id\": \"$REPO_ID\",
\"status\": \"running\",
\"workflow_run_id\": ${{ github.run_id }},
\"workflow_url\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\",
\"current_stage\": \"inline-docs\"
}"
HTTP_CODE=$(send_webhook "$CALLBACK_URL" "$WEBHOOK_SECRET" "$PAYLOAD" "/tmp/webhook_start_response.txt") || HTTP_CODE="failed"
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ]; then
echo "Hub acknowledged the run start (HTTP $HTTP_CODE)"
else
echo "::warning title=Run start not reported::The hub answered HTTP $HTTP_CODE to the start callback. The run continues; the hub learns its status from the stage callbacks."
fi
# =========================================================================
# DOWNLOAD PIPELINE SCRIPTS
# Every script a documentation run uses, from the hub's authenticated
# /api/ci/scripts surface (workflow-helpers.sh arrived in "Download report
# helpers"), plus the source vocabulary and the Markdown guidelines.
# =========================================================================
- name: Download pipeline scripts
id: helpers
env:
WEBHOOK_SECRET: ${{ secrets.FLAMINGO_HUB_SECRET }}
# No HASH_* pins: the digests come from manifest.json on the same
# endpoint that serves the scripts, so a hash in this file can never
# be a different ref's than the bytes it checks.
run: |
echo "::group::Download the doc-pipeline script group"
# Function to download and verify script
SCRIPT_MANIFEST=/tmp/flamingo-script-manifest.json
# WEBHOOK_SECRET reaches curl through a 0600 config file, never argv — see
# curlAuthPreamble, which always traps the removal.
CURL_CFG=$(mktemp) && chmod 600 "$CURL_CFG"
trap 'rm -f "$CURL_CFG"' EXIT
printf 'header = "Authorization: Bearer %s"\n' "$WEBHOOK_SECRET" > "$CURL_CFG"
# The scripts surface. load_script_manifest pins SCRIPTS_BASE_URL to it.
CI_SCRIPTS_URL="${HUB_BASE_URL%/}/api/ci/scripts"
# _try_manifest <base> — 0 loaded, 1 no manifest surface there, 2 fatal.
# The manifest is asked for ONE group: its keys are the files to download.
_try_manifest() {
local base="$1" code
code=$(curl -sS -w '%{http_code}' -o "$SCRIPT_MANIFEST" \
-K "$CURL_CFG" \
"$base/manifest.json?group=$SCRIPT_GROUP") || code="000"
if [ "$code" = "404" ]; then rm -f "$SCRIPT_MANIFEST"; return 1; fi
if [ "$code" != "200" ]; then
echo "❌ manifest request to $base failed (HTTP $code)"
rm -f "$SCRIPT_MANIFEST"
return 2
fi
# The digests are the TOP-LEVEL object. successResponse is the standard
# emitter but it does NOT add a wrapper — it is NextResponse.json(data)
# plus the no-store header — so there is no .data to reach through.
# A 200 that is not a manifest is how a hub which does not serve this path
# answers (the proxy rewrites unknown routes and returns HTML), so it
# means "wrong surface", not "corrupt".
if ! jq -e 'type == "object" and length > 0 and (to_entries | all(.value | type == "string"))' "$SCRIPT_MANIFEST" >/dev/null 2>&1; then
rm -f "$SCRIPT_MANIFEST"
return 1
fi
return 0
}
# load_script_manifest <group>
load_script_manifest() {
SCRIPT_GROUP="$1"
# "cmd; rc=$?" dies under the set -euo pipefail these steps run with —
# errexit fires before rc is read and the step ends with NO output. And
# "if ! cmd; then rc=$?" is worse: inside the branch $? is the status of
# the NEGATION (0), so every failure reads as success. "|| rc=$?" is the
# one form that both suppresses errexit and preserves the real code.
local rc=0
_try_manifest "$CI_SCRIPTS_URL" || rc=$?
if [ "$rc" = "0" ]; then
SCRIPTS_BASE_URL="$CI_SCRIPTS_URL"
echo "✅ script manifest loaded ($(jq -r 'length' "$SCRIPT_MANIFEST") scripts)"
return 0
fi
if [ "$rc" = "2" ]; then exit 1; fi
# No manifest on the scripts surface: a hub older than the manifest
# itself. The manifest is the file list, so there is nothing to download.
echo "❌ no script manifest on this hub ($HUB_BASE_URL): it cannot name the $SCRIPT_GROUP scripts. Redeploy the hub."
exit 1
}
# download_script_group <group> — the hub names the files, this workflow
# names only the group. Downloads every script of the group, in served order.
download_script_group() {
load_script_manifest "$1"
local name
# The loop runs in THIS shell (no pipe), so a failed download exits the step.
while IFS= read -r name; do
download_and_verify "$name"
done < <(jq -r 'keys_unsorted[]' "$SCRIPT_MANIFEST")
}
download_and_verify() {
local script_name="$1"
local output_path="/tmp/$script_name"
local expected_hash
expected_hash=$(jq -r --arg n "$script_name" '.[$n] // empty' "$SCRIPT_MANIFEST")
if [ -z "$expected_hash" ]; then
echo "❌ $script_name is not in the server's script manifest!"
echo " The hub serves no such script, or it failed to read on the server."
exit 1
fi
if ! printf '%s' "$expected_hash" | grep -Eq '^[0-9a-f]{64}$'; then
echo "❌ the manifest entry for $script_name is not a SHA-256 digest — refusing to run it."
exit 1
fi
curl -fsSL "$SCRIPTS_BASE_URL/$script_name" \
-K "$CURL_CFG" \
-o "$output_path"
local actual_hash=$(shasum -a 256 "$output_path" | cut -d' ' -f1)
if [ "$actual_hash" != "$expected_hash" ]; then
echo "❌ HASH MISMATCH for $script_name!"
echo " Expected: $expected_hash"
echo " Actual: $actual_hash"
echo " The download was corrupted in transit — both values come from the same deployment."
exit 1
fi
# Make shell scripts executable
if [[ "$script_name" == *.sh ]]; then
chmod +x "$output_path"
fi
echo "✅ $script_name verified (hash: ${actual_hash:0:16}...)"
}
# Every script a documentation run uses, stage 0 (the code graph) included.
# Digests AND the file list come from the deployment serving the bytes,
# not from this file: the hub lists the group in download order (a helper
# a generator require()s at load comes before it).
download_script_group "doc-pipeline"
echo "::endgroup::"
# ONE vocabulary read for the whole run: every later step (language
# detection, source discovery, the generators, the graph build) reads this
# file, so none of them needs the secret for it.
node /tmp/ci-source.mjs vocabulary /tmp/ci-vocabulary.json || { echo "::error title=Source vocabulary unavailable::Could not read the source vocabulary from the hub."; exit 1; }
echo "CODE_GRAPH_VOCABULARY_FILE=/tmp/ci-vocabulary.json" >> $GITHUB_ENV
echo "Pipeline scripts and source vocabulary downloaded and verified"
# Export paths for all stages (use os.tmpdir() compatible paths)
echo "VALIDATION_RULES_PATH=/tmp/markdown-validation-rules.md" >> $GITHUB_ENV
echo "GUIDELINES_PATH=/tmp/flamingo-markdown-guidelines.md" >> $GITHUB_ENV
echo "STAGE3_FILES_TRACKER=/tmp/stage3-files.txt" >> $GITHUB_ENV
echo "STAGE3_STATS_FILE=/tmp/.doc-stage3-stats.json" >> $GITHUB_ENV
echo "STAGE4_FILES_TRACKER=/tmp/stage4-files.txt" >> $GITHUB_ENV
# Flamingo Markdown guidelines (their own endpoint). REQUIRED: the
# Markdown validation and the CodeWiki prompts both read them.
GUIDELINES_URL="${HUB_BASE_URL}/api/code-documentation/guidelines"
# Same 0600 config file the download block above set up.
HTTP_CODE=$(curl -fsSL -w "%{http_code}" \
"$GUIDELINES_URL" \
-K "$CURL_CFG" \
-o "/tmp/flamingo-markdown-guidelines.md" 2>/dev/null) || HTTP_CODE="failed"
if [ "$HTTP_CODE" = "200" ]; then
GUIDELINES_SIZE=$(wc -c < /tmp/flamingo-markdown-guidelines.md | tr -d ' ')
if [ "$GUIDELINES_SIZE" -lt 100 ]; then
echo "::error title=Markdown guidelines invalid::The guidelines file is $GUIDELINES_SIZE bytes, which is an error response, not guidelines. Its body follows."
cat /tmp/flamingo-markdown-guidelines.md
exit 1
fi
echo "Markdown guidelines downloaded ($GUIDELINES_SIZE bytes)"
else
echo "::error title=Markdown guidelines unavailable::GET $GUIDELINES_URL answered HTTP $HTTP_CODE. The Markdown validation and the CodeWiki prompts require them; check that the hub serves the guidelines endpoint."
rm -f /tmp/flamingo-markdown-guidelines.md
exit 1
fi
# (The run-started report sits ABOVE the main script download; see the
# report-capability step ordering at the top of the job.)
- name: Check out repository
# v5 = the Node 24 drop-in (v4 targets EOL Node 20 and warns on every run).
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ env.SOURCE_BRANCH }}
# The SOURCE head, before the PR branch and the docs-removal commit move
# HEAD: the stage-0 graph build tags this commit (the code being
# documented), never the docs branch it is sitting on.
- name: Record source head
run: echo "SOURCE_HEAD_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
# =========================================================================
# DEPENDENCY REPOSITORIES (when configured): cloned beside the checkout as
# context for every stage.
# =========================================================================
- name: Clone dependency repositories
if: env.DEPENDENCIES != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Authenticates the clone-token request to the hub (masked by the runner).
WEBHOOK_SECRET: ${{ secrets.FLAMINGO_HUB_SECRET }}
run: |
source /tmp/workflow-helpers.sh
echo "Cloning dependency repositories: $DEPENDENCIES"
ensure_directory "../deps"
# The clone credential is minted by the hub for THIS run: read-only,
# scoped to exactly this repository's dependencies, valid one hour.
# A stored repository secret cannot hold it (an installation token
# expires an hour after it is written).
CLONE_TOKEN=""
TOKEN_FILE=$(mktemp) && chmod 600 "$TOKEN_FILE"
HTTP_CODE=$(node /tmp/ci-hub.mjs get "/api/ci/code-documentation/clone-token?github=${GITHUB_REPOSITORY}" "$TOKEN_FILE") || HTTP_CODE="000"
if [ "$HTTP_CODE" = "200" ]; then
CLONE_TOKEN=$(jq -r '.token // empty' "$TOKEN_FILE")
fi
rm -f "$TOKEN_FILE"
if [ -n "$CLONE_TOKEN" ]; then
echo "::add-mask::$CLONE_TOKEN"
echo "Credential: a read-only token the hub minted for this run's dependencies"
else
echo "::warning title=No clone token::The hub did not mint a clone token (HTTP $HTTP_CODE); using GITHUB_TOKEN, which reads public repositories only."
CLONE_TOKEN="$GH_TOKEN"
fi
# The credential applies to THESE clones only (`git -c`), never globally:
# the token reads the dependencies and nothing else, so a global URL
# rewrite would also send this job's own pushes through it.
IFS=',' read -ra DEPS <<< "$DEPENDENCIES"
for dep in "${DEPS[@]}"; do
repo_name=$(basename $dep)
echo "::group::Clone $dep into ../deps/$repo_name"
if git -c "url.https://x-access-token:${CLONE_TOKEN}@github.com/.insteadOf=https://github.com/" \
clone --depth 1 "https://github.com/$dep.git" "../deps/$repo_name" 2>&1; then
git -C "../deps/$repo_name" remote set-url origin "https://github.com/$dep.git"
file_count=$(node /tmp/ci-source.mjs count "../deps/$repo_name" 2>/dev/null || echo "?")
echo "Cloned $dep ($file_count source files)"
else
echo "::warning title=Dependency not cloned::Could not clone $dep."
fi
echo "::endgroup::"
done
echo "::group::Dependency directories"
ls -la ../deps/ 2>/dev/null || echo "No dependencies cloned"
echo "::endgroup::"
echo "Dependency source files available as context: $(node /tmp/ci-source.mjs count ../deps 2>/dev/null || echo '?')"
# =========================================================================
# PRIMARY LANGUAGE (before every stage, so all of them agree): picks the
# stage 2 engine and filters stages 1 to 3.
# =========================================================================
- name: Detect primary language
id: detect_language
run: |
source /tmp/workflow-helpers.sh
# This step is the FIRST reader of CODEWIKI_CONFIG_JSON — it runs before
# "Validate and parse run configuration", so the emptiness guard lives here,
# ahead of the first jq, rather than in the later validation step.
if [ -z "$CODEWIKI_CONFIG_JSON" ]; then
echo "::error title=Missing configuration::CODEWIKI_CONFIG_JSON is empty; language detection needs it."
exit 1
fi
echo "Detecting the primary language across the repository (.) and its dependencies (../deps/)"
# ONE detection, from the vocabulary the hub serves (ci-source.mjs): which
# languages are source, their extensions, and which of them CodeWiki can
# parse are rows in the hub's language table. This step used to carry nine
# hand-typed `find` counts, a positional helper and a six-way threshold
# test, each with its own idea of the extensions and the exclusions.
DETECTION=$(node /tmp/ci-source.mjs detect) || { echo "::error title=Language detection failed::ci-source.mjs detect exited non-zero."; exit 1; }
PRIMARY_LANG=$(echo "$DETECTION" | jq -r '.primary')
MAX_COUNT=$(echo "$DETECTION" | jq -r '.max')
CODEWIKI_SUPPORTED=$(echo "$DETECTION" | jq -r '.codewiki_supported')
echo "::group::Source files by language (tests and never-source directories excluded)"
echo "$DETECTION" | jq -r '.counts | to_entries[] | select(.value > 0) | " \(.key): \(.value)"'
echo "::endgroup::"
echo "Primary language: $PRIMARY_LANG ($MAX_COUNT files)"
if [ "$CODEWIKI_SUPPORTED" = "true" ]; then
echo "CodeWiki can parse it: yes"
else
echo "CodeWiki can parse it: no (stage 2 uses the Claude architecture analysis)"
fi
# Per-repo engine override.
#
# The detection above cannot see mixed repos: it counts `.` AND `../deps`, so
# a Rust or Go product with a TypeScript dependency clones its way past the
# >=10 threshold and runs CodeWiki over a codebase whose analyzers do not
# exist — which yields synthetic module_1/module_2/... docs that look like a
# successful run. `engine` pins the choice.
#
# Applied here, before set_output, so all five downstream gates keep reading
# one value and need no change. It cannot live in the `if:` conditions:
# GitHub Actions expressions have no ternary.
CODEWIKI_ENGINE=$(require_json_key "$CODEWIKI_CONFIG_JSON" '.engine' 'codewiki engine') || exit 1
case "$CODEWIKI_ENGINE" in
claude)
CODEWIKI_SUPPORTED="false"
echo "Stage 2 engine: claude (pinned by the repository configuration)"
;;
codewiki)
CODEWIKI_SUPPORTED="true"
echo "Stage 2 engine: codewiki (pinned by the repository configuration)"
;;
auto)
echo "Stage 2 engine: auto (CodeWiki supported: $CODEWIKI_SUPPORTED)"
;;
*)
echo "::error title=Invalid stage 2 engine::engine is '$CODEWIKI_ENGINE'; expected auto, codewiki or claude."
exit 1
;;
esac
# Output for use by subsequent steps
set_output "primary_language" "$PRIMARY_LANG"
set_output "codewiki_supported" "$CODEWIKI_SUPPORTED"
set_output "file_count" "$MAX_COUNT"