Repository navigation
2090 lines (1926 loc) · 110 KB
/
Copy pathimpl-generate.yml
File metadata and controls
2090 lines (1926 loc) · 110 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
name: "Impl: Generate"
run-name: "Generate: ${{ inputs.library || github.event.label.name }} for ${{ inputs.specification_id || 'issue' }}"
# Generates single library implementation
# Triggers:
# - generate:{library} label on spec-ready issue
# - workflow_dispatch with specification_id + library
# Adopt mode (workflow_dispatch with source_ref): the implementation file is
# taken from that ref of origin instead of being generated, then rendered,
# reviewed, repaired and merged like a forced regeneration. source_model
# names the model that wrote it, recorded as generated_by.
on:
issues:
types: [labeled]
workflow_dispatch:
inputs:
specification_id:
description: "Specification ID (e.g., scatter-basic)"
required: true
type: string
library:
description: "Library to generate"
required: true
type: choice
options:
- matplotlib
- seaborn
- plotly
- bokeh
- altair
- plotnine
- pygal
- highcharts
- letsplot
- ggplot2
- makie
- chartjs
- d3
- echarts
- muix
issue_number:
description: "Issue number (optional, for tracking)"
required: false
type: string
model:
description: "Generation model (also threaded into repair); auto = opus for the pair's first implementation, sonnet for a regeneration. Reviews always run on opus (impl-review.yml)."
required: false
type: choice
default: 'auto'
options:
- auto
- haiku
- sonnet
- opus
change_request:
description: "One-sentence cross-library divergence hint from daily-regen pre-flight similarity audit (empty = none)"
required: false
type: string
default: ''
regen_gate:
description: "Regen gate: when the pair already has an implementation on main, one review decides keep vs replace (no repair). false = forced regen through the old fresh-generation path (repair loop, cascading thresholds), labelled regen:forced"
required: false
type: boolean
default: true
source_ref:
description: "Adopt mode: take the implementation file from this branch, tag or commit SHA of origin (e.g. main, gemini/muix-9) instead of generating it with Claude. Always a forced regeneration (regen:forced); model still threads to repair. Empty = generate."
required: false
type: string
default: ''
source_model:
description: "Adopt mode only: the model that wrote the adopted implementation (e.g. gemini-4-argon), recorded as generated_by. Only with source_ref; empty keeps generated_by: adopted:<ref>."
required: false
type: string
default: ''
# Global concurrency: max 3 concurrent implementation workflows
concurrency:
group: impl-generate-${{ inputs.specification_id || github.event.issue.number }}-${{ inputs.library || github.event.label.name }}
cancel-in-progress: false
jobs:
generate:
# Run on label trigger OR workflow_dispatch
if: >
(github.event_name == 'workflow_dispatch') ||
(github.event_name == 'issues' && startsWith(github.event.label.name, 'generate:'))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
actions: write
id-token: write
outputs:
success: ${{ steps.result.outputs.success }}
pr_number: ${{ steps.pr.outputs.pr_number }}
steps:
# Claude credentials: the repository variable CLAUDE_AUTH picks one mode
# per run, and every Claude step below reads this step's outputs, so a
# new mode changes only this step. Unset or `subscription`:
# CLAUDE_CODE_OAUTH_TOKEN, as before; `api`: ANTHROPIC_API_KEY; `oidc`:
# workload identity federation with the four CLAUDE_WIF_* variables,
# which needs `id-token: write` on the job. Each Claude step gets
# exactly one credential. An unknown value, or a mode whose credential
# is missing, stops the job here, never a silent fallback to the
# subscription. Identical in every job that runs Claude
# (tests/unit/workflows/test_claude_auth.py);
# docs/workflows/overview.md#claude-credentials.
# First step: a stop here precedes "Set final issue number", so "Handle
# generation failure" never counts it against the pair's attempts.
- name: Resolve Claude credentials
id: claude_auth
env:
CLAUDE_AUTH: ${{ vars.CLAUDE_AUTH }}
API_KEY_SET: ${{ secrets.ANTHROPIC_API_KEY != '' }}
OAUTH_TOKEN_SET: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }}
WIF_RULE_ID: ${{ vars.CLAUDE_WIF_RULE_ID }}
WIF_ORG_ID: ${{ vars.CLAUDE_WIF_ORG_ID }}
WIF_SERVICE_ACCOUNT_ID: ${{ vars.CLAUDE_WIF_SERVICE_ACCOUNT_ID }}
WIF_WORKSPACE_ID: ${{ vars.CLAUDE_WIF_WORKSPACE_ID }}
EVENT: ${{ github.event_name }}
REF: ${{ github.ref }}
run: |
fail() { echo "::error::$1; not falling back to the subscription"; exit 1; }
mode="${CLAUDE_AUTH,,}"
case "$mode" in
''|subscription) mode=subscription ;;
api) [ "$API_KEY_SET" = "true" ] || fail "CLAUDE_AUTH is 'api' but the ANTHROPIC_API_KEY secret is missing" ;;
oidc)
# The federation rule accepts only `…:ref:refs/heads/main`. A
# pull-request event (`…:pull_request`), another ref, and a job
# without id-token run on the subscription instead, by design
# and logged, never on an error.
if [[ "$EVENT" == pull_request* || "$REF" != "refs/heads/main" || -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]]; then
where="${EVENT}/${REF//[^A-Za-z0-9\/._-]/?}$([ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] || echo ' (no id-token)')"
if [ "$OAUTH_TOKEN_SET" != "true" ]; then
echo "::error::oidc not available for ${where}, and the CLAUDE_CODE_OAUTH_TOKEN secret is missing"
exit 1
fi
echo "::notice::oidc not available for ${where}; using subscription"
echo "- oidc not available for ${where}; using subscription" >> "$GITHUB_STEP_SUMMARY"
mode=subscription
else
for name in WIF_RULE_ID WIF_ORG_ID WIF_SERVICE_ACCOUNT_ID WIF_WORKSPACE_ID; do
[[ "${!name}" =~ ^[A-Za-z0-9_-]+$ ]] || fail "CLAUDE_AUTH is 'oidc' but the variable CLAUDE_${name} is missing or not an ID"
done
{
echo "federation_rule_id=$WIF_RULE_ID"
echo "organization_id=$WIF_ORG_ID"
echo "service_account_id=$WIF_SERVICE_ACCOUNT_ID"
echo "workspace_id=$WIF_WORKSPACE_ID"
} >> "$GITHUB_OUTPUT"
fi ;;
*) fail "CLAUDE_AUTH must be unset, 'subscription', 'api' or 'oidc', got '${CLAUDE_AUTH//[^A-Za-z0-9 ._-]/?}'" ;;
esac
echo "mode=$mode" >> "$GITHUB_OUTPUT"
echo "Claude credentials: $mode"
# ========================================================================
# Setup: Extract inputs and validate
# ========================================================================
# Checkout comes first: "Extract inputs" resolves the model from
# origin/main (first implementation vs. regeneration), and every model
# consumer below reads that one resolved value.
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Extract inputs
id: inputs
env:
LABEL_NAME: ${{ github.event.label.name }}
ISSUE_TITLE: ${{ github.event.issue.title }}
# Same expression as "Check for existing implementation": only an
# explicit dispatch with regen_gate=false, or one that adopts a file
# from source_ref, forces a regeneration.
REGEN_GATE: ${{ (github.event_name == 'workflow_dispatch' && (inputs.regen_gate == false || inputs.source_ref != '')) && 'false' || 'true' }}
# Via env, never interpolated into the script: free text from a dispatch.
SOURCE_REF_INPUT: ${{ inputs.source_ref }}
SOURCE_MODEL_INPUT: ${{ inputs.source_model }}
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
# From workflow_dispatch
SPEC_ID="${{ inputs.specification_id }}"
LIBRARY="${{ inputs.library }}"
ISSUE="${{ inputs.issue_number }}"
MODEL="${{ inputs.model }}"
else
# From label trigger: generate:{library}
LIBRARY=$(echo "$LABEL_NAME" | sed 's/^generate://')
# Extract spec ID from issue title: [spec-id] ...
SPEC_ID=$(echo "$ISSUE_TITLE" | sed -n 's/^\[\([a-z0-9-]*\)\].*/\1/p')
ISSUE="${{ github.event.issue.number }}"
MODEL=""
fi
if [ -z "$SPEC_ID" ]; then
echo "::error::Could not determine specification ID"
exit 1
fi
if [ -z "$LIBRARY" ]; then
echo "::error::Could not determine library"
exit 1
fi
# Adopt mode: the implementation comes from this ref of origin
# ("Adopt implementation from source ref") instead of Claude.
# `git fetch origin <ref>` names the ref as origin itself knows it,
# so a leading "origin/" (this checkout's remote-tracking name) is
# dropped. The value reaches git, the PR body and the metadata, so
# only branch, tag and SHA characters pass. A non-empty input never
# becomes an empty ref: the REGEN_GATE expressions read the raw
# input, so it must always mean adopt mode. A rejected value is
# never echoed: a newline in it would start a workflow command.
SOURCE_REF="${SOURCE_REF_INPUT:-}"
SOURCE_REF="${SOURCE_REF#origin/}"
if [ -n "${SOURCE_REF_INPUT:-}" ]; then
if ! [[ "$SOURCE_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]{0,79}$ ]] || [[ "$SOURCE_REF" == *..* ]]; then
echo "::error::source_ref is not a branch, tag or commit SHA (letters, digits, . _ / -, at most 80 characters)"
exit 1
fi
fi
# The model that wrote the adopted file outside the pipeline, such
# as gemini-4-argon. It becomes the metadata's generated_by and
# reaches the PR body, so only model-name characters pass, checked
# before anything echoes it, and a rejected value is not echoed at
# all. Without source_ref nothing is adopted, so the value is
# dropped with a warning instead of naming a model that never wrote
# this run's code.
SOURCE_MODEL="${SOURCE_MODEL_INPUT:-}"
if [ -n "$SOURCE_MODEL" ]; then
if ! [[ "$SOURCE_MODEL" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$ ]]; then
echo "::error::source_model is not a model name (letters, digits, . _ -, at most 40 characters)"
exit 1
fi
if [ -z "$SOURCE_REF" ]; then
echo "::warning::source_model '${SOURCE_MODEL}' ignored: it names the author of an adopted file and needs source_ref"
SOURCE_MODEL=""
fi
fi
# Derive LANGUAGE + EXT from LIBRARY. Mirrors core/constants.py LIBRARIES_METADATA;
# new non-Python entries get listed here too.
case "$LIBRARY" in
ggplot2)
LANGUAGE="r"
EXT=".R"
;;
makie)
LANGUAGE="julia"
EXT=".jl"
;;
chartjs|d3|echarts|highcharts)
LANGUAGE="javascript"
EXT=".js"
;;
muix)
# MUI X — JavaScript with framework=react, authored as TSX and
# bundled through the render harness's esbuild React branch.
LANGUAGE="javascript"
EXT=".tsx"
;;
*)
LANGUAGE="python"
EXT=".py"
;;
esac
# Model routing. An explicit model always wins: any dispatch input
# other than "auto", including the model a failed run forwards to its
# own retry. Without one ("auto" from bulk-generate, daily-regen or
# the watchdog; empty on the label trigger) a pair's first
# implementation runs on opus and a regeneration on sonnet, except a
# forced one (regen_gate=false, or adopt mode), which runs on opus.
# In adopt mode no model generates; the value only runs repair. "First"
# means no implementation file on origin/main yet. The resolved value
# is threaded into repair and recorded in the PR body, so one PR
# generates and repairs on one model. Reviews are not routed here:
# impl-review.yml runs every review on opus (its review_model input).
if [ -n "$MODEL" ] && [ "$MODEL" != "auto" ]; then
MODEL_REASON="explicit input"
else
# Without origin/main every pair would look like a first run and
# silently land on opus, so stop instead of guessing.
if ! git rev-parse -q --verify origin/main >/dev/null \
&& ! git fetch -q origin +refs/heads/main:refs/remotes/origin/main; then
echo "::error::origin/main unavailable — cannot route the model"
exit 1
fi
if git cat-file -e "origin/main:plots/$SPEC_ID/implementations/$LANGUAGE/$LIBRARY$EXT" 2>/dev/null; then
if [ "$REGEN_GATE" = "false" ]; then
# A forced regeneration skips the regen gate and goes through
# the fresh-generation path (repair loop, cascading
# thresholds), so it runs on opus like a first implementation.
MODEL="opus"
MODEL_REASON="forced regeneration"
else
MODEL="sonnet"
MODEL_REASON="regeneration"
fi
else
MODEL="opus"
MODEL_REASON="first implementation"
fi
fi
echo "specification_id=$SPEC_ID" >> $GITHUB_OUTPUT
echo "library=$LIBRARY" >> $GITHUB_OUTPUT
echo "language=$LANGUAGE" >> $GITHUB_OUTPUT
echo "ext=$EXT" >> $GITHUB_OUTPUT
echo "issue_number=$ISSUE" >> $GITHUB_OUTPUT
echo "model=$MODEL" >> $GITHUB_OUTPUT
echo "model_reason=$MODEL_REASON" >> $GITHUB_OUTPUT
echo "source_ref=$SOURCE_REF" >> $GITHUB_OUTPUT
echo "source_model=$SOURCE_MODEL" >> $GITHUB_OUTPUT
if [ -n "$SOURCE_REF" ]; then
# No pipeline model writes the code (source_model, when given,
# names the one that did); the resolved model still runs repair.
{
echo "**Generation:** adopted from \`${SOURCE_REF}\`${SOURCE_MODEL:+ by ${SOURCE_MODEL}}"
echo ""
echo "**Repair model:** ${MODEL} (${MODEL_REASON})"
} >> "$GITHUB_STEP_SUMMARY"
echo "::notice::Adopting $LANGUAGE/$LIBRARY for $SPEC_ID (issue: ${ISSUE:-none}, generation: adopted from ${SOURCE_REF}${SOURCE_MODEL:+ by ${SOURCE_MODEL}}, repair model: ${MODEL} (${MODEL_REASON}), ext: ${EXT})"
else
echo "**Model:** ${MODEL} (${MODEL_REASON})" >> "$GITHUB_STEP_SUMMARY"
echo "::notice::Generating $LANGUAGE/$LIBRARY for $SPEC_ID (issue: ${ISSUE:-none}, model: ${MODEL} (${MODEL_REASON}), ext: ${EXT})"
fi
- name: Read issue number from specification.yaml (fallback)
id: spec_issue
if: steps.inputs.outputs.issue_number == ''
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
run: |
SPEC_YAML="plots/${SPEC_ID}/specification.yaml"
if [ -f "$SPEC_YAML" ]; then
# Extract issue number using yq for robust YAML parsing
ISSUE=$(yq '.issue' "$SPEC_YAML" 2>/dev/null || echo "")
if [ -n "$ISSUE" ] && [ "$ISSUE" != "null" ]; then
echo "issue_number=$ISSUE" >> $GITHUB_OUTPUT
echo "::notice::Found issue #$ISSUE from specification.yaml"
else
echo "issue_number=" >> $GITHUB_OUTPUT
echo "::warning::No issue number in specification.yaml"
fi
else
echo "issue_number=" >> $GITHUB_OUTPUT
echo "::warning::specification.yaml not found"
fi
# Consolidate issue number from inputs or fallback
- name: Set final issue number
id: issue
run: |
ISSUE="${{ steps.inputs.outputs.issue_number || steps.spec_issue.outputs.issue_number }}"
echo "number=$ISSUE" >> $GITHUB_OUTPUT
if [ -n "$ISSUE" ]; then
echo "::notice::Using issue #$ISSUE for tracking"
else
echo "::warning::No issue number available - PR will not have Parent Issue link"
fi
- name: Validate specification exists
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
run: |
if [ ! -f "plots/${SPEC_ID}/specification.md" ]; then
echo "::error::Specification not found: plots/${SPEC_ID}/specification.md"
exit 1
fi
- name: Reopen issue if closed
if: steps.issue.outputs.number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE: ${{ steps.issue.outputs.number }}
run: |
# Reopen issue so it's visible during implementation
gh issue reopen "$ISSUE" 2>/dev/null || true
- name: Add pending label
if: steps.issue.outputs.number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LIBRARY: ${{ steps.inputs.outputs.library }}
ISSUE: ${{ steps.issue.outputs.number }}
run: |
gh issue edit "$ISSUE" --add-label "impl:${LIBRARY}:pending" 2>/dev/null || true
# ========================================================================
# Setup: Python and dependencies (always installed — needed for image
# processing + metadata writing even for non-Python implementations).
# ========================================================================
- name: Set up Python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.14'
- name: Install uv
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y pngquant
- name: Install Python plotting dependencies
if: steps.inputs.outputs.language == 'python'
env:
LIBRARY: ${{ steps.inputs.outputs.library }}
run: |
# The lib-${LIBRARY} extra exactly as uv.lock pins it (`--frozen`: never
# re-resolve), the way agents/renderer/Dockerfile and CI install. The
# former `uv pip install -e ".[lib-X]"` resolved pyproject.toml's ranges
# afresh, so Dependabot's lock bumps never reached the pipeline and the
# recorded library_version drifted from the lock. The base set carries
# pillow and pyyaml; `dev` carries ruff. Creates .venv.
uv sync --frozen --extra "lib-${LIBRARY}" --extra dev
- name: Install Python helper deps (for non-Python implementations)
if: steps.inputs.outputs.language != 'python'
run: |
# ggplot2 / future non-Python libs render via their own runtime,
# but the pipeline still uses Python for metadata + image post-processing.
uv venv .venv
source .venv/bin/activate
uv pip install pillow pyyaml
- name: Setup R + ggplot2
if: steps.inputs.outputs.language == 'r'
uses: ./.github/actions/setup-r
- name: Setup Julia + Makie
if: steps.inputs.outputs.language == 'julia'
uses: ./.github/actions/setup-julia
- name: Setup Node + browser render harness
if: steps.inputs.outputs.language == 'javascript'
uses: ./.github/actions/setup-node
# ========================================================================
# Generate: Create implementation branch and code
# ========================================================================
- name: Create implementation branch
id: branch
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LIBRARY: ${{ steps.inputs.outputs.library }}
run: |
BRANCH="implementation/${SPEC_ID}/${LIBRARY}"
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
# Delete branch if exists (regeneration)
git push origin --delete "$BRANCH" 2>/dev/null || true
# Create fresh branch from main
git checkout -b "$BRANCH" origin/main
echo "::notice::Created branch: $BRANCH"
- name: Check for existing implementation (regeneration)
id: existing
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
LIBRARY: ${{ steps.inputs.outputs.library }}
EXT: ${{ steps.inputs.outputs.ext }}
# Label-triggered runs have no inputs: only an explicit dispatch with
# regen_gate=false, or one that adopts from source_ref, opts out
# (null == false is true in expressions, hence the event_name guard).
REGEN_GATE: ${{ (github.event_name == 'workflow_dispatch' && (inputs.regen_gate == false || inputs.source_ref != '')) && 'false' || 'true' }}
SOURCE_REF: ${{ steps.inputs.outputs.source_ref }}
run: |
METADATA_FILE="plots/${SPEC_ID}/metadata/${LANGUAGE}/${LIBRARY}.yaml"
IMPL_FILE="plots/${SPEC_ID}/implementations/${LANGUAGE}/${LIBRARY}${EXT}"
if [ -f "$METADATA_FILE" ] && [ -f "$IMPL_FILE" ]; then
echo "is_regeneration=true" >> $GITHUB_OUTPUT
echo "::notice::Regeneration detected - will read previous review feedback"
else
echo "is_regeneration=false" >> $GITHUB_OUTPUT
fi
# Regen gate marker. impl-review.yml decides "regen" on its own from
# origin/main (implementation file present) — these labels are for
# humans and the watchdog, except `regen:forced`, which opts the PR
# out of the gate. Keyed on the implementation file alone, the same
# test impl-review uses.
if [ -f "$IMPL_FILE" ]; then
if [ "$REGEN_GATE" = "false" ]; then
echo "regen_label=regen:forced" >> $GITHUB_OUTPUT
if [ -n "$SOURCE_REF" ]; then
echo "::notice::Forced regeneration (adopted from ${SOURCE_REF}) — fresh-generation review path"
else
echo "::notice::Forced regeneration (regen_gate=false) — fresh-generation review path"
fi
else
echo "regen_label=regen" >> $GITHUB_OUTPUT
echo "::notice::Gated regeneration — one review decides keep vs replace"
fi
else
echo "regen_label=" >> $GITHUB_OUTPUT
fi
- name: Extract previous review feedback (regeneration)
id: prev_review
if: steps.existing.outputs.is_regeneration == 'true'
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
LIBRARY: ${{ steps.inputs.outputs.library }}
run: |
# Shared extractor (also used by impl-review.yml): writes
# /tmp/anyplot-prev-review.md with stable weakness ids W1..Wn (each
# tagged defect, suggestion or older review), the same ids the regen
# review refers to in review_regen.json, and the spec's
# characteristic bullets C1..Cn, so the generator can name the
# bullet that makes a weakness obsolete.
.venv/bin/python3 automation/scripts/regen_gate.py context \
--metadata "plots/${SPEC_ID}/metadata/${LANGUAGE}/${LIBRARY}.yaml" \
--spec-id "$SPEC_ID" --language "$LANGUAGE" --library "$LIBRARY" \
--spec-file "plots/${SPEC_ID}/specification.md" \
--out-md /tmp/anyplot-prev-review.md \
--out-weaknesses /tmp/anyplot-prev-weaknesses.json
echo "::notice::Extracted previous review to /tmp/anyplot-prev-review.md"
- name: Ensure implementation directories exist
env:
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
run: |
mkdir -p "plots/${SPEC_ID}/implementations/${LANGUAGE}"
mkdir -p "plots/${SPEC_ID}/metadata/${LANGUAGE}"
echo "::notice::Ensured implementation + metadata directories exist for language '${LANGUAGE}'"
- name: Stage change_request hint (cross-library divergence)
if: ${{ inputs.change_request != '' }}
env:
CHANGE_REQUEST: ${{ inputs.change_request }}
run: |
# Written to a file so the prompt template stays variable-free; impl-generate-claude.md
# checks for the file's existence and reads it if present.
printf '%s\n' "$CHANGE_REQUEST" > /tmp/anyplot-change-request.txt
echo "::notice::Change request staged: ${CHANGE_REQUEST}"
# When triggered from a non-main branch via workflow_dispatch (typical for
# iterative prompt-change testing), overlay the trigger branch's prompts/
# directory onto the implementation branch (which was just created from
# main). Claude commits only the implementation file (see Step 7 of
# impl-generate-claude.md → `git add plots/.../{LIBRARY}{EXT}`), so the
# overlaid prompts/ never end up in the resulting PR. The later "Create
# library metadata file" step re-checks out origin/$BRANCH, which discards
# the overlay automatically. No-op on main + on label triggers.
#
# FETCH_HEAD is used instead of origin/<name> so the step also works for
# tag and SHA refs (where origin/<name> isn't created). The existence
# check for prompts/ guards refs without that tree. impl-generate-claude.md
# is always restored from the implementation branch (= main) after the
# overlay, so a branch can't override Claude's `git add plots/...` safety
# contract by editing that one file. Adopt mode runs no Claude step, so
# it skips the overlay and the PR never carries prompts/.
- name: Overlay prompts/ from trigger ref (branch-level prompt iteration)
if: github.ref_name != 'main' && github.event_name == 'workflow_dispatch' && steps.inputs.outputs.source_ref == ''
env:
TRIGGER_REF: ${{ github.ref_name }}
run: |
if ! git fetch origin "$TRIGGER_REF" 2>/dev/null; then
echo "::notice::Trigger ref $TRIGGER_REF not found on origin — skipping overlay (workflow runs against main's prompts)"
exit 0
fi
if ! git ls-tree FETCH_HEAD -- prompts/ 2>/dev/null | grep -q .; then
echo "::notice::Trigger ref $TRIGGER_REF has no prompts/ tree — skipping overlay"
exit 0
fi
git checkout FETCH_HEAD -- prompts/
# Always restore impl-generate-claude.md from main: it tells Claude
# `git add plots/.../{LIBRARY}{EXT}` (Step 7), which is what keeps
# the overlaid prompts/ out of the resulting PR. Letting a feature
# branch override that instruction defeats the safety contract above.
git checkout HEAD -- prompts/workflow-prompts/impl-generate-claude.md
echo "::notice::Overlaid prompts/ from $TRIGGER_REF (impl-generate-claude.md kept from main for safety)"
# Adopt mode (source_ref set): the implementation comes from a ref of
# origin, typically a `gemini/*` branch that migrated it, instead of
# from Claude. The step does what the generator does in Steps 3, 3b and
# 7 of impl-generate-claude.md: it takes exactly the file the generator
# would write, renders it in both themes with the same commands, checks
# the canvas with core.canvas, and commits and pushes that one file to
# the implementation branch. Every later step runs as for a generated
# file. Python and JavaScript only: R and Julia have no adopt render yet.
# A failure of the source writes `cause` first (missing, render,
# unsupported), so "Handle generation failure" knows that every retry
# would read the same file and fail the same way. A failure of the
# runner (origin unreachable, numpy not installable, the canvas check
# crashing, the push) writes none and takes the ordinary retry path.
- name: Adopt implementation from source ref
id: adopt
if: steps.inputs.outputs.source_ref != ''
timeout-minutes: 20
env:
SOURCE_REF: ${{ steps.inputs.outputs.source_ref }}
SOURCE_MODEL: ${{ steps.inputs.outputs.source_model }}
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
LIBRARY: ${{ steps.inputs.outputs.library }}
EXT: ${{ steps.inputs.outputs.ext }}
BRANCH: ${{ steps.branch.outputs.branch }}
run: |
# This step runs the adopted, model-written code: it gets no way to
# mint an OIDC token (the job has id-token for Claude and Google Cloud).
unset ACTIONS_ID_TOKEN_REQUEST_URL ACTIONS_ID_TOKEN_REQUEST_TOKEN
IMPL_DIR="plots/${SPEC_ID}/implementations/${LANGUAGE}"
IMPL_FILE="${IMPL_DIR}/${LIBRARY}${EXT}"
AUTHOR="${SOURCE_MODEL:+, written by ${SOURCE_MODEL}}"
fail() {
echo "cause=$1" >> "$GITHUB_OUTPUT"
echo "::error::$2"
exit 1
}
case "$LANGUAGE" in
python|javascript) ;;
*) fail unsupported "Adopt mode renders Python and JavaScript implementations only; ${LANGUAGE}/${LIBRARY} has no adopt render yet" ;;
esac
# FETCH_HEAD, not origin/<name>: works for branches, tags and SHAs
# alike. A failed fetch is a missing ref only when origin answers
# otherwise; an unreachable origin is the runner's failure.
if ! git fetch --no-tags origin "$SOURCE_REF"; then
if ! git ls-remote --heads origin main >/dev/null; then
echo "::error::origin is unreachable; cannot fetch source_ref '${SOURCE_REF}'"
exit 1
fi
fail missing "source_ref '${SOURCE_REF}' not found on origin (expected a branch, tag or full commit SHA)"
fi
SOURCE_SHA=$(git rev-parse FETCH_HEAD)
echo "sha=${SOURCE_SHA}" >> "$GITHUB_OUTPUT"
if ! git cat-file -e "FETCH_HEAD:${IMPL_FILE}" 2>/dev/null; then
fail missing "${IMPL_FILE} does not exist on source_ref ${SOURCE_REF} (${SOURCE_SHA})"
fi
git checkout FETCH_HEAD -- "$IMPL_FILE"
echo "::notice::Adopted ${IMPL_FILE} from ${SOURCE_REF} (${SOURCE_SHA})${AUTHOR}"
# Step 3: both themes, from the implementation directory, with the
# generator's commands. The JS harness writes plot-{theme}.png and
# plot-{theme}.html itself and exits non-zero on a throw or an
# empty #container.
for f in plot-light.png plot-dark.png plot-light.html plot-dark.html plot.png plot.html; do
rm -f "${IMPL_DIR}/${f}"
done
for theme in light dark; do
if [ "$LANGUAGE" = "python" ]; then
( source .venv/bin/activate && cd "$IMPL_DIR" \
&& MPLBACKEND=Agg ANYPLOT_THEME="$theme" python -P "${LIBRARY}.py" ) \
|| fail render "${IMPL_FILE} failed to render the ${theme} theme"
else
( cd "$IMPL_DIR" \
&& ANYPLOT_THEME="$theme" node "$GITHUB_WORKSPACE/automation/js-render/render.mjs" "${LIBRARY}${EXT}" ) \
|| fail render "${IMPL_FILE} failed to render the ${theme} theme"
fi
[ -s "${IMPL_DIR}/plot-${theme}.png" ] || fail render "${IMPL_FILE} rendered no plot-${theme}.png"
done
# Step 3b, with core.canvas on both themes. Drift only warns: the
# canvas gate in impl-review turns it into a VQ-05 defect that
# repair fixes, the path a generated file that drifted takes too.
# core.canvas needs numpy, which the helper venv of the non-Python
# languages lacks. Exit 1 is drift only when the check printed its
# failing verdict: a crash (an import error, say) exits 1 as well,
# and must never pass for a measured canvas.
if ! .venv/bin/python -c "import numpy" 2>/dev/null; then
uv pip install --python .venv/bin/python numpy \
&& .venv/bin/python -c "import numpy" \
|| { echo "::error::numpy could not be installed for the canvas check"; exit 1; }
fi
for theme in light dark; do
rc=0
verdict=$(.venv/bin/python -m core.canvas "${IMPL_DIR}/plot-${theme}.png" --library "$LIBRARY") || rc=$?
printf '%s\n' "$verdict"
if [ "$rc" = 1 ] && grep -q '^::notice::canvas_gate .* status=fail ' <<< "$verdict"; then
echo "::warning::plot-${theme}.png is off the canvas target; impl-review's canvas gate sends it to repair"
elif [ "$rc" = 2 ]; then
fail render "plot-${theme}.png of ${IMPL_FILE} is not a readable PNG"
elif [ "$rc" != 0 ]; then
echo "::error::the canvas check crashed on plot-${theme}.png (exit ${rc})"
exit 1
fi
done
# Step 7: commit only the implementation file and push the branch.
# --allow-empty keeps the adopt commit when the file equals main's
# (source_ref=main), so the PR still records where the code came from.
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if [ -n "$SOURCE_MODEL" ]; then
ORIGIN_NOTE="Adopted from ${SOURCE_REF} (${SOURCE_SHA}), written by ${SOURCE_MODEL} outside the pipeline."
else
ORIGIN_NOTE="Adopted from ${SOURCE_REF} (${SOURCE_SHA}); no generation model."
fi
git commit --allow-empty -m "feat(${LIBRARY}): implement ${SPEC_ID}" -m "$ORIGIN_NOTE" -- "$IMPL_FILE"
git push -u origin "$BRANCH"
echo "**Adopted:** \`${IMPL_FILE}\` from \`${SOURCE_REF}\` (${SOURCE_SHA})${AUTHOR}" >> "$GITHUB_STEP_SUMMARY"
- name: Run Claude Code to generate implementation
id: claude
if: steps.inputs.outputs.source_ref == ''
continue-on-error: true
timeout-minutes: 60
uses: anthropics/claude-code-action@d70510b174a1244958df0b03166a54912811f3e6 # v1
with:
anthropic_api_key: ${{ steps.claude_auth.outputs.mode == 'api' && secrets.ANTHROPIC_API_KEY || '' }}
claude_code_oauth_token: ${{ steps.claude_auth.outputs.mode == 'subscription' && secrets.CLAUDE_CODE_OAUTH_TOKEN || '' }}
anthropic_federation_rule_id: ${{ steps.claude_auth.outputs.federation_rule_id }}
anthropic_organization_id: ${{ steps.claude_auth.outputs.organization_id }}
anthropic_service_account_id: ${{ steps.claude_auth.outputs.service_account_id }}
anthropic_workspace_id: ${{ steps.claude_auth.outputs.workspace_id }}
claude_args: "--model ${{ steps.inputs.outputs.model }} --settings ${{ github.workspace }}/.claude/settings.json"
# bulk-generate dispatches us from the github-actions bot; explicitly allow it.
allowed_bots: '*'
prompt: |
Read `prompts/workflow-prompts/impl-generate-claude.md` and follow those instructions.
Variables for this run:
- LANGUAGE: ${{ steps.inputs.outputs.language }}
- LIBRARY: ${{ steps.inputs.outputs.library }}
- EXT: ${{ steps.inputs.outputs.ext }}
- SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
- IS_REGENERATION: ${{ steps.existing.outputs.is_regeneration }}
# The session's cost (Claude Code's own estimate at API list price),
# model and credential mode, for the weekly regen review, and whether
# Claude stopped it: a spend or usage limit, or refused credentials
# (outputs stopped, stop_reason). Read right after the step: the retry
# writes the same execution file. Never fatal.
- name: Claude session summary
id: claude_cost
if: ${{ !cancelled() && (steps.claude.outcome == 'success' || steps.claude.outcome == 'failure') }}
continue-on-error: true
env:
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
CLAUDE_AUTH_MODE: ${{ steps.claude_auth.outputs.mode }}
CLAUDE_STEP: impl-generate
# Only the error fields of a session that failed, because its own
# text may quote the same messages: the error result, Claude Code's
# error messages and its API retries, with their HTTP status.
SESSION_ERRORS: >-
([.[] | select(.type == "result")] | last) as $result
| if $result != null and $result.is_error != true then empty else
(.[] | select((.type == "result" and .is_error == true) or (.type == "assistant" and .error != null) or (.type == "system" and .subtype == "api_retry"))
| (.result // ""), ((.errors // [])[] | tostring), (.error // "" | tostring), (if .type == "assistant" then ((.message.content // [])[] | .text? // "") else "" end), (if (.api_error_status // .error_status) then "api_error_status=\(.api_error_status // .error_status)" else "" end))
end
# A spend or usage limit, in every mode: the API's spend-limit,
# spend-cap and credit messages, Claude Code's billing_error and
# credit message, and its subscription usage-limit messages (the
# SDK's USAGE_LIMIT_ERROR_PREFIXES).
LIMIT_PATTERN: >-
reached your (specified )?(workspace )?API usage limits|enforced_spend_limit_reached|credit balance (is )?too low|billing_error|usage limit reached|You.{1,3}ve (hit|reached) your|You.{1,3}re out of (usage credits|extra usage)|Your org is out of usage|Your seat type doesn.{1,3}t include|Your usage allocation has been disabled|Your group.{1,3}s usage limit is set to \$0|requires usage credits
# Refused credentials, with api and oidc only (the subscription keeps
# its handling): HTTP 401 or 403, the API's authentication_error and
# permission_error, Claude Code's authentication_failed, a refused
# federation exchange ("Authentication failed"), a bad API key.
AUTH_PATTERN: >-
authentication_error|authentication_failed|permission_error|Authentication failed|invalid x-api-key|account_on_hold|api_error_status=40[13]
run: |
cost=$(jq -r '[.[] | select(.type == "result")] | last | .total_cost_usd // empty' "$EXECUTION_FILE" 2>/dev/null || true)
model=$(jq -r '[.[] | select(.type == "system" and .subtype == "init")] | first | .model // empty' "$EXECUTION_FILE" 2>/dev/null || true)
line="claude-cost step=${CLAUDE_STEP} auth=${CLAUDE_AUTH_MODE} model=${model:-n/a} total_cost_usd=${cost:-n/a}"
echo "$line"
echo "- \`${line}\`" >> "$GITHUB_STEP_SUMMARY"
errors=$(jq -r "$SESSION_ERRORS" "$EXECUTION_FILE" 2>/dev/null || true)
reason=""
if grep -qiE "$LIMIT_PATTERN" <<< "$errors"; then
reason=limit
what="Claude spend/usage limit reached"
elif [[ "$CLAUDE_AUTH_MODE" =~ ^(api|oidc)$ ]] && grep -qiE "$AUTH_PATTERN" <<< "$errors"; then
reason=auth
what="Claude credentials refused"
fi
if [ -n "$reason" ]; then
echo "::error::${what} (auth=${CLAUDE_AUTH_MODE}) in ${CLAUDE_STEP}: no retry until it is resolved"
echo "- **${what} (auth=${CLAUDE_AUTH_MODE})** in \`${CLAUDE_STEP}\`" >> "$GITHUB_STEP_SUMMARY"
echo "stopped=true" >> "$GITHUB_OUTPUT"
else
echo "stopped=false" >> "$GITHUB_OUTPUT"
fi
echo "stop_reason=${reason}" >> "$GITHUB_OUTPUT"
# Not after Claude stopped the session (a limit or refused credentials):
# the retry would be refused the same way.
- name: Retry Claude (on failure)
if: steps.claude.outcome == 'failure' && steps.inputs.outputs.source_ref == '' && steps.claude_cost.outputs.stopped != 'true'
id: claude_retry
timeout-minutes: 60
uses: anthropics/claude-code-action@d70510b174a1244958df0b03166a54912811f3e6 # v1
with:
anthropic_api_key: ${{ steps.claude_auth.outputs.mode == 'api' && secrets.ANTHROPIC_API_KEY || '' }}
claude_code_oauth_token: ${{ steps.claude_auth.outputs.mode == 'subscription' && secrets.CLAUDE_CODE_OAUTH_TOKEN || '' }}
anthropic_federation_rule_id: ${{ steps.claude_auth.outputs.federation_rule_id }}
anthropic_organization_id: ${{ steps.claude_auth.outputs.organization_id }}
anthropic_service_account_id: ${{ steps.claude_auth.outputs.service_account_id }}
anthropic_workspace_id: ${{ steps.claude_auth.outputs.workspace_id }}
claude_args: "--model ${{ steps.inputs.outputs.model }} --settings ${{ github.workspace }}/.claude/settings.json"
# bulk-generate dispatches us from the github-actions bot; explicitly allow it.
allowed_bots: '*'
prompt: |
Read `prompts/workflow-prompts/impl-generate-claude.md` and follow those instructions.
Variables for this run:
- LANGUAGE: ${{ steps.inputs.outputs.language }}
- LIBRARY: ${{ steps.inputs.outputs.library }}
- EXT: ${{ steps.inputs.outputs.ext }}
- SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
- IS_REGENERATION: ${{ steps.existing.outputs.is_regeneration }}
- name: Claude session summary (retry)
id: claude_retry_cost
if: ${{ !cancelled() && (steps.claude_retry.outcome == 'success' || steps.claude_retry.outcome == 'failure') }}
continue-on-error: true
env:
EXECUTION_FILE: ${{ steps.claude_retry.outputs.execution_file }}
CLAUDE_AUTH_MODE: ${{ steps.claude_auth.outputs.mode }}
CLAUDE_STEP: impl-generate/retry
# Only the error fields of a session that failed, because its own
# text may quote the same messages: the error result, Claude Code's
# error messages and its API retries, with their HTTP status.
SESSION_ERRORS: >-
([.[] | select(.type == "result")] | last) as $result
| if $result != null and $result.is_error != true then empty else
(.[] | select((.type == "result" and .is_error == true) or (.type == "assistant" and .error != null) or (.type == "system" and .subtype == "api_retry"))
| (.result // ""), ((.errors // [])[] | tostring), (.error // "" | tostring), (if .type == "assistant" then ((.message.content // [])[] | .text? // "") else "" end), (if (.api_error_status // .error_status) then "api_error_status=\(.api_error_status // .error_status)" else "" end))
end
# A spend or usage limit, in every mode: the API's spend-limit,
# spend-cap and credit messages, Claude Code's billing_error and
# credit message, and its subscription usage-limit messages (the
# SDK's USAGE_LIMIT_ERROR_PREFIXES).
LIMIT_PATTERN: >-
reached your (specified )?(workspace )?API usage limits|enforced_spend_limit_reached|credit balance (is )?too low|billing_error|usage limit reached|You.{1,3}ve (hit|reached) your|You.{1,3}re out of (usage credits|extra usage)|Your org is out of usage|Your seat type doesn.{1,3}t include|Your usage allocation has been disabled|Your group.{1,3}s usage limit is set to \$0|requires usage credits
# Refused credentials, with api and oidc only (the subscription keeps
# its handling): HTTP 401 or 403, the API's authentication_error and
# permission_error, Claude Code's authentication_failed, a refused
# federation exchange ("Authentication failed"), a bad API key.
AUTH_PATTERN: >-
authentication_error|authentication_failed|permission_error|Authentication failed|invalid x-api-key|account_on_hold|api_error_status=40[13]
run: |
cost=$(jq -r '[.[] | select(.type == "result")] | last | .total_cost_usd // empty' "$EXECUTION_FILE" 2>/dev/null || true)
model=$(jq -r '[.[] | select(.type == "system" and .subtype == "init")] | first | .model // empty' "$EXECUTION_FILE" 2>/dev/null || true)
line="claude-cost step=${CLAUDE_STEP} auth=${CLAUDE_AUTH_MODE} model=${model:-n/a} total_cost_usd=${cost:-n/a}"
echo "$line"
echo "- \`${line}\`" >> "$GITHUB_STEP_SUMMARY"
errors=$(jq -r "$SESSION_ERRORS" "$EXECUTION_FILE" 2>/dev/null || true)
reason=""
if grep -qiE "$LIMIT_PATTERN" <<< "$errors"; then
reason=limit
what="Claude spend/usage limit reached"
elif [[ "$CLAUDE_AUTH_MODE" =~ ^(api|oidc)$ ]] && grep -qiE "$AUTH_PATTERN" <<< "$errors"; then
reason=auth
what="Claude credentials refused"
fi
if [ -n "$reason" ]; then
echo "::error::${what} (auth=${CLAUDE_AUTH_MODE}) in ${CLAUDE_STEP}: no retry until it is resolved"
echo "- **${what} (auth=${CLAUDE_AUTH_MODE})** in \`${CLAUDE_STEP}\`" >> "$GITHUB_STEP_SUMMARY"
echo "stopped=true" >> "$GITHUB_OUTPUT"
else
echo "stopped=false" >> "$GITHUB_OUTPUT"
fi
echo "stop_reason=${reason}" >> "$GITHUB_OUTPUT"
# Claude stopped the first session (a limit or refused credentials) and
# its retry was skipped: fail here, so "Handle generation failure" parks
# the pair instead of the steps below running without an implementation.
- name: Stop when Claude stopped the session
if: steps.claude_cost.outputs.stopped == 'true'
run: |
echo "::error::Claude stopped the session (${{ steps.claude_cost.outputs.stop_reason }}); the retry is skipped"
exit 1
# ========================================================================
# Create metadata file (before PR)
# ========================================================================
- name: Create library metadata file
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SPEC_ID: ${{ steps.inputs.outputs.specification_id }}
LANGUAGE: ${{ steps.inputs.outputs.language }}
LIBRARY: ${{ steps.inputs.outputs.library }}
EXT: ${{ steps.inputs.outputs.ext }}
ISSUE: ${{ steps.issue.outputs.number }}
BRANCH: ${{ steps.branch.outputs.branch }}
MODEL: ${{ steps.inputs.outputs.model }}
# SOURCE_REF and SOURCE_MODEL are validated in "Extract inputs" (ref
# and model-name characters only). SOURCE_SHA is the commit "Adopt
# implementation from source ref" took the file from.
SOURCE_REF: ${{ steps.inputs.outputs.source_ref }}
SOURCE_MODEL: ${{ steps.inputs.outputs.source_model }}
SOURCE_SHA: ${{ steps.adopt.outputs.sha }}
run: |
IMPL_DIR="plots/${SPEC_ID}/implementations/${LANGUAGE}"
# Save plot files before git operations (they are not committed and would be lost).
# Phase C emits plot-light.png + plot-dark.png (+ plot-light.html + plot-dark.html
# for interactive libs); legacy plot.png/plot.html is preserved during the transition.
mkdir -p /tmp/anyplot-plot-cache
rm -rf /tmp/anyplot-plot-cache/*
for f in plot-light.png plot-dark.png plot-light.html plot-dark.html plot.png plot.html; do
cp "${IMPL_DIR}/${f}" "/tmp/anyplot-plot-cache/${f}" 2>/dev/null || true
done
# Configure git auth (Claude's action configured it, but it's gone now)
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
# Sync with remote (Claude already pushed the code)
git fetch origin
# Check if remote branch exists before checkout (fixes branch-not-found error)
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
git checkout -B "$BRANCH" "origin/$BRANCH"
else
# Branch doesn't exist on remote - create fresh from main
echo "::warning::Remote branch $BRANCH not found, creating fresh from main"
git checkout -B "$BRANCH" origin/main
fi
# An implementation PR never changes dependency files, and impl-merge.yml
# refuses one that does. #9398 (qrcode-basic, makie) committed the
# Manifest.toml that a `Pkg.add` had re-resolved, which moved Makie from
# 0.22.10 to 0.21.9 for every Julia implementation. A spec that needs a
# new package needs a dependency PR first.
# The diff runs on its own line so a git failure stops the step
# instead of reading as "no changes". `--no-renames` lists both sides
# of a rename, so Manifest.toml -> Manifest.toml.bak is caught too.
CHANGED=$(git diff --no-renames --name-only origin/main...HEAD)
DEP_CHANGES=$(echo "$CHANGED" \
| grep -E '(^|/)(pyproject\.toml|uv\.lock|package\.json|package-lock\.json|Project\.toml|Manifest\.toml|\.python-version)$' || true)
if [ -n "$DEP_CHANGES" ]; then
echo "::error::The implementation commit changes dependency files: $(echo "$DEP_CHANGES" | tr '\n' ' '). Implementations use only the installed packages; a new package needs its own dependency PR first."
exit 1
fi
# Restore plot files after git operations
mkdir -p "${IMPL_DIR}"
for f in plot-light.png plot-dark.png plot-light.html plot-dark.html plot.png plot.html; do
cp "/tmp/anyplot-plot-cache/${f}" "${IMPL_DIR}/${f}" 2>/dev/null || true
done
# Now create metadata file
METADATA_DIR="plots/${SPEC_ID}/metadata/${LANGUAGE}"
mkdir -p "${METADATA_DIR}"
METADATA_FILE="${METADATA_DIR}/${LIBRARY}.yaml"
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
mkdir -p "$METADATA_DIR"
# python_version = the Python interpreter that ran THIS pipeline (3.14 today).
# language_version = the implementation's own runtime — same as python_version for
# Python libs, the R version for ggplot2. The frontend renders language_version with
# the right language label, so we never mislabel R as "Python".
PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}')
# R/Python versions are always dot-separated digits. Anything else
# captured from Rscript (renv leaks, error fragments, status messages)
# would poison metadata and break the downstream Python heredoc, so
# we reject and let the existing "unknown" fallback take over.
is_version() {
[[ "$1" =~ ^[0-9]+(\.[0-9]+)*$ ]]
}
if [ "$LANGUAGE" = "r" ]; then
# renv's "out-of-sync" notice prints on stdout from .Rprofile, so 2>/dev/null
# leaves it in the captured value. RENV_CONFIG_STARTUP_QUIET silences renv;
# tail -n1 + is_version reject anything else that might leak in or land if
# Rscript exits non-zero after partial stdout.
LANGUAGE_VERSION=$(RENV_CONFIG_STARTUP_QUIET=TRUE Rscript -e 'cat(as.character(getRversion()))' 2>/dev/null | tail -n1)
is_version "$LANGUAGE_VERSION" || LANGUAGE_VERSION="unknown"
elif [ "$LANGUAGE" = "julia" ]; then
# `julia -e 'print(VERSION)'` prints `1.11.2` (or similar) on stdout.
# tail -n1 + is_version reject anything else that might leak in or land
# if julia exits non-zero after partial stdout.
LANGUAGE_VERSION=$(julia -e 'print(VERSION)' 2>/dev/null | tail -n1)
is_version "$LANGUAGE_VERSION" || LANGUAGE_VERSION="unknown"
elif [ "$LANGUAGE" = "javascript" ]; then
# `node --version` prints `v22.x.y`; strip the leading `v`.
LANGUAGE_VERSION=$(node --version 2>/dev/null | sed 's/^v//' | tail -n1)
is_version "$LANGUAGE_VERSION" || LANGUAGE_VERSION="unknown"
else
LANGUAGE_VERSION="$PYTHON_VERSION"
fi
# Get library version. Python libs read via `pip show`; R libs via
# packageVersion() inside Rscript; Julia libs via Pkg API.
# Names that differ between catalogue id and registry id are mapped
# explicitly.
get_pip_version() {
.venv/bin/pip show "$1" 2>/dev/null | grep -i "^Version:" | awk '{print $2}'
}