Repository navigation
1504 lines (1357 loc) · 66.2 KB
/
Copy pathpublish.yml
File metadata and controls
1504 lines (1357 loc) · 66.2 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
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
name: Publish
on:
workflow_dispatch:
inputs:
dry_run:
description: "Dry run (build/test only, no actual publish)"
type: boolean
default: true
use_latest_ci:
description: "Use latest CI configuration and scripts from master branch (recommended for compatibility)"
type: boolean
required: false
default: true
skip_tag_creation:
description: "Skip creating git tags (useful for re-publishing or testing)"
type: boolean
required: false
default: false
commit:
description: "Commit SHA to publish from"
type: string
required: true
publish_crates:
description: "Rust crates to publish (comma-separated: rust-sdk, rust-cli, rust-binary-protocol, rust-common)"
type: string
required: false
default: ""
publish_dockerhub:
description: "Docker images to publish (comma-separated: rust-server, rust-mcp, rust-bench-dashboard, rust-connectors, rust-kafka-gateway, web-ui)"
type: string
required: false
default: ""
publish_other:
description: "Other SDKs to publish (comma-separated: python, node, java, csharp, go)"
type: string
required: false
default: ""
create_edge_docker_tag:
description: "Create rolling :edge Docker tag (used by post-merge automation)"
type: boolean
required: false
default: false
env:
IGGY_CI_BUILD: true
permissions:
contents: write # For tag creation
packages: write
# Static group so two concurrent release dispatches cannot race the
# crates.io / Maven Central / npm / NuGet / DockerHub / PyPI uploads.
# `run_id` in the group was effectively a no-op because it is unique
# per dispatch. `cancel-in-progress: false` keeps any in-flight upload
# running to completion rather than leaving partial state in registries.
concurrency:
group: publish-release
cancel-in-progress: false
queue: max
jobs:
validate:
name: Validate inputs
runs-on: ubuntu-26.04
outputs:
commit: ${{ steps.resolve.outputs.commit }}
has_targets: ${{ steps.check.outputs.has_targets }}
steps:
- uses: actions/checkout@v7.0.1
with:
fetch-depth: 0
- name: Check if any targets specified
id: check
run: |
if [ -z "${{ inputs.publish_crates }}" ] && \
[ -z "${{ inputs.publish_dockerhub }}" ] && \
[ -z "${{ inputs.publish_other }}" ]; then
echo "has_targets=false" >> "$GITHUB_OUTPUT"
else
echo "has_targets=true" >> "$GITHUB_OUTPUT"
fi
- name: Resolve commit
id: resolve
env:
INPUT_COMMIT: ${{ inputs.commit }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
COMMIT="${INPUT_COMMIT}"
if [ -z "$COMMIT" ]; then
COMMIT="${{ github.sha }}"
echo "ℹ️ No commit specified, using github.sha: $COMMIT"
fi
# Validate AND normalize to the canonical full 40-char SHA in one
# shot. Downstream jobs (rust crates, docker manifests, SDK matrix,
# tag creation) all need the same fully-qualified SHA so they cannot
# drift if HEAD moves on the input branch mid-run.
FULL_SHA=$(git rev-parse --verify "${COMMIT}^{commit}" 2>/dev/null || true)
if [ -z "$FULL_SHA" ]; then
echo "❌ Invalid commit: $COMMIT"
exit 1
fi
if ! [[ "$FULL_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "❌ git rev-parse returned non-canonical SHA: $FULL_SHA"
exit 1
fi
if [ "$FULL_SHA" != "$COMMIT" ]; then
echo "ℹ️ Normalized $COMMIT -> $FULL_SHA"
fi
COMMIT="$FULL_SHA"
if [ "$DRY_RUN" = "true" ]; then
echo "🌵 Dry run, skipping master branch check"
else
echo "🔍 Verifying commit is on master branch..."
git fetch origin master --depth=1000
if git merge-base --is-ancestor "$COMMIT" origin/master; then
echo "✅ Commit is on master branch"
else
echo "❌ ERROR: Commit $COMMIT is not on the master branch!"
echo ""
echo "Publishing is only allowed from commits on the master branch."
echo "Please ensure your commit has been merged to master before publishing."
echo ""
echo "To check which branch contains this commit, run:"
echo " git branch -r --contains $COMMIT"
exit 1
fi
fi
echo "commit=$COMMIT" >> "$GITHUB_OUTPUT"
echo "✅ Will publish from commit: $COMMIT"
echo
echo "Commit details:"
git log -1 --pretty=format:" Author: %an <%ae>%n Date: %ad%n Subject: %s" "$COMMIT"
validate-licenses:
name: Validate third-party licenses (release gate)
needs: validate
runs-on: ubuntu-26.04
# Re-runs the same per-artifact validation that pre-merge enforces,
# against the resolved release commit. Tags can point at commits
# older than the pre-merge gate, or be created via direct push that
# bypassed PR review, so a release-time gate is required to keep the
# policy contract intact. Composite action is shared with
# .github/workflows/_common.yml.
steps:
- uses: actions/checkout@v7.0.1
with:
ref: ${{ needs.validate.outputs.commit }}
- uses: ./.github/actions/utils/validate-third-party-licenses
plan:
name: Build publish plan
needs: [validate, validate-licenses]
if: needs.validate.outputs.has_targets == 'true'
runs-on: ubuntu-26.04
outputs:
targets: ${{ steps.mk.outputs.targets }}
crates: ${{ steps.mk.outputs.crates }}
non_docker_targets: ${{ steps.mk.outputs.non_docker_targets }}
docker_matrix: ${{ steps.mk.outputs.docker_matrix }}
docker_components: ${{ steps.mk.outputs.docker_components }}
count: ${{ steps.mk.outputs.count }}
has_python: ${{ steps.mk.outputs.has_python }}
has_docker: ${{ steps.mk.outputs.has_docker }}
steps:
- name: Download latest copy script from master
if: inputs.use_latest_ci
run: |
# Download the copy script from master branch
curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \
-o /tmp/copy-latest-from-master.sh
chmod +x /tmp/copy-latest-from-master.sh
echo "✅ Downloaded latest copy script from master"
- uses: actions/checkout@v7.0.1
with:
ref: ${{ needs.validate.outputs.commit }}
- name: Save and apply latest CI from master
if: inputs.use_latest_ci
run: |
# Save latest files from master (including config)
/tmp/copy-latest-from-master.sh save \
.github \
scripts
# Apply them to current checkout
/tmp/copy-latest-from-master.sh apply
- name: Load publish config
id: cfg
run: |
if ! command -v yq &> /dev/null; then
YQ_VERSION="v4.47.1"
YQ_CHECKSUM="0fb28c6680193c41b364193d0c0fc4a03177aecde51cfc04d506b1517158c2fb"
wget -qO /tmp/yq https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64
echo "${YQ_CHECKSUM} /tmp/yq" | sha256sum -c - || exit 1
chmod +x /tmp/yq && sudo mv /tmp/yq /usr/local/bin/yq
fi
echo "components_b64=$(yq -o=json -I=0 '.components' .github/config/publish.yml | base64 -w0)" >> "$GITHUB_OUTPUT"
- name: Cache Cargo metadata
if: inputs.create_edge_docker_tag && !inputs.skip_tag_creation && inputs.publish_crates != ''
env:
IGGY_CARGO_METADATA_FILE: ${{ runner.temp }}/iggy-cargo-metadata.json
run: |
cargo metadata --format-version 1 --no-deps > "$IGGY_CARGO_METADATA_FILE"
echo "IGGY_CARGO_METADATA_FILE=$IGGY_CARGO_METADATA_FILE" >> "$GITHUB_ENV"
- name: Build matrix from inputs
id: mk
uses: actions/github-script@v9
with:
script: |
const { execFileSync } = require('node:child_process');
const componentsB64 = '${{ steps.cfg.outputs.components_b64 }}';
const cfg = JSON.parse(Buffer.from(componentsB64, 'base64').toString('utf-8') || "{}");
const skipReleased = '${{ inputs.create_edge_docker_tag }}' === 'true' &&
'${{ inputs.skip_tag_creation }}' !== 'true';
const wants = [];
// Parse Rust crates
('${{ inputs.publish_crates }}').split(',').map(s => s.trim()).filter(Boolean).forEach(crate => {
if (['rust-sdk','rust-cli','rust-binary-protocol','rust-common'].includes(crate)) wants.push(crate);
else core.warning(`Unknown crate: ${crate}`);
});
// Parse Docker images
('${{ inputs.publish_dockerhub }}').split(',').map(s => s.trim()).filter(Boolean).forEach(img => {
if (cfg[img]?.registry === 'dockerhub') wants.push(img);
else core.warning(`Unknown Docker image: ${img}`);
});
// Parse other SDKs
('${{ inputs.publish_other }}').split(',').map(s => s.trim()).filter(Boolean).forEach(sdk => {
if (['python','node','java','csharp','go'].includes(sdk)) {
wants.push(`sdk-${sdk}`);
} else {
core.warning(`Unknown SDK: ${sdk}`);
}
});
const toType = (entry) => ({
dockerhub: 'docker',
crates: 'rust',
pypi: 'python',
npm: 'node',
maven: 'java',
nuget: 'csharp',
none: 'go'
}[entry.registry] || 'unknown');
const targets = [];
const seen = new Set();
for (const key of wants) {
if (seen.has(key)) continue;
seen.add(key);
const entry = cfg[key];
if (!entry) { core.warning(`Component '${key}' not found in publish.yml`); continue; }
// A queued dispatch can contain releases completed by the previous publisher.
if (skipReleased && entry.registry !== 'dockerhub') {
const shouldTag = execFileSync('bash',
['scripts/extract-version.sh', key, '--should-tag'], { encoding: 'utf-8' }).trim();
if (shouldTag === 'true') {
const tag = execFileSync('bash',
['scripts/extract-version.sh', key, '--tag'], { encoding: 'utf-8' }).trim();
const remoteTag = execFileSync('git',
['ls-remote', '--tags', 'origin', `refs/tags/${tag}`], { encoding: 'utf-8' }).trim();
if (remoteTag) {
core.info(`Skipping ${key}: ${tag} is already released`);
continue;
}
}
}
const target = {
key,
name: key,
type: toType(entry),
registry: entry.registry || '',
package: entry.package || '',
image: entry.image || '',
dockerfile: entry.dockerfile || '',
platforms: Array.isArray(entry.platforms) ? entry.platforms.join(',') : '',
tag_pattern: entry.tag_pattern || '',
version_file: entry.version_file || '',
version_regex: entry.version_regex || ''
};
targets.push(target);
}
// Separate Docker targets from other non-Rust targets
const nonRustTargets = targets.filter(t => t.type !== 'rust');
const dockerTargets = nonRustTargets.filter(t => t.type === 'docker');
const nonDockerTargets = nonRustTargets.filter(t => t.type !== 'docker');
console.log(`Publishing ${targets.length} components:`);
targets.forEach(t => console.log(` - ${t.name} (${t.type}) -> ${t.registry || 'N/A'}`));
console.log(` (${nonRustTargets.length} non-Rust, ${targets.length - nonRustTargets.length} Rust crates)`);
console.log(` (${dockerTargets.length} Docker, ${nonDockerTargets.length} other SDKs)`);
// Output all targets for reference and tag creation
core.setOutput('targets', JSON.stringify(targets.length ? { include: targets } : { include: [{ key: 'noop', type: 'noop' }] }));
core.setOutput('crates', targets.filter(t => t.type === 'rust').map(t => t.key).join(','));
// Output non-Docker, non-Rust targets (SDKs only)
core.setOutput('non_docker_targets', JSON.stringify(nonDockerTargets.length ? { include: nonDockerTargets } : { include: [{ key: 'noop', type: 'noop' }] }));
// Build Docker matrix: components × platforms for native runner builds
const platforms = [
{ platform: 'linux/amd64', arch: 'amd64', runner: 'ubuntu-26.04' },
{ platform: 'linux/arm64', arch: 'arm64', runner: 'ubuntu-26.04-arm' }
];
const dockerMatrix = [];
for (const t of dockerTargets) {
for (const p of platforms) {
dockerMatrix.push({ ...t, ...p });
}
}
core.setOutput('docker_matrix', JSON.stringify(dockerMatrix.length ? { include: dockerMatrix } : { include: [{ key: 'noop', type: 'noop' }] }));
core.setOutput('docker_components', JSON.stringify(dockerTargets.length ? { include: dockerTargets } : { include: [{ key: 'noop', type: 'noop' }] }));
core.setOutput('has_docker', String(dockerTargets.length > 0));
core.setOutput('count', String(targets.length));
// Check if Python SDK is in targets and extract version
const pythonTarget = targets.find(t => t.key === 'sdk-python');
if (pythonTarget) {
core.setOutput('has_python', 'true');
// Python version will be extracted in the publish job
} else {
core.setOutput('has_python', 'false');
}
check-tags:
name: Existing tags
needs: [validate, plan]
if: needs.validate.outputs.has_targets == 'true' && fromJson(needs.plan.outputs.targets).include[0].key != 'noop'
runs-on: ubuntu-26.04
steps:
- name: Download latest copy script from master
if: inputs.use_latest_ci
run: |
curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \
-o /tmp/copy-latest-from-master.sh
chmod +x /tmp/copy-latest-from-master.sh
echo "✅ Downloaded latest copy script from master"
- name: Checkout at commit
uses: actions/checkout@v7.0.1
with:
ref: ${{ needs.validate.outputs.commit }}
# check-tags only runs `git ls-remote --tags` against origin,
# which does not need local history. fetch-depth:1 is enough.
fetch-depth: 1
- name: Save and apply latest CI from master
if: inputs.use_latest_ci
run: |
/tmp/copy-latest-from-master.sh save \
.github \
scripts
/tmp/copy-latest-from-master.sh apply
- name: Setup yq
run: |
YQ_VERSION="v4.47.1"
YQ_CHECKSUM="0fb28c6680193c41b364193d0c0fc4a03177aecde51cfc04d506b1517158c2fb"
sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64
echo "${YQ_CHECKSUM} /usr/local/bin/yq" | sha256sum -c - || exit 1
sudo chmod +x /usr/local/bin/yq
- name: Check for existing tags
run: |
set -euo pipefail
echo "## 🏷️ Tag Existence Check" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ "${{ inputs.skip_tag_creation }}" = "true" ]; then
echo "### ℹ️ Tag Creation Disabled" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Tag creation has been explicitly disabled for this run." >> "$GITHUB_STEP_SUMMARY"
echo "Components will be published without creating git tags." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
fi
TARGETS_JSON='${{ needs.plan.outputs.targets }}'
EXISTING_TAGS=()
NEW_TAGS=()
WRONG_TARGET_TAGS=() # populated when a tag exists at a commit != this run's target
# Every tag step is gated on dry_run == false at job or step level, and
# skip_tag_creation suppresses the tag push, so neither mode may
# render the "Will create" cell that promises a real tag write.
if [ "${{ inputs.dry_run }}" = "true" ]; then
NEW_TAG_CELL="🔍 Would create (dry run)"
NEW_TAG_LOG="🔍 Tag would be created (dry run):"
elif [ "${{ inputs.skip_tag_creation }}" = "true" ]; then
NEW_TAG_CELL="ℹ️ Would create (tag creation disabled)"
NEW_TAG_LOG="ℹ️ Tag would be created (tag creation disabled):"
else
NEW_TAG_CELL="✅ Will create"
NEW_TAG_LOG="✅ Tag will be created:"
fi
echo "| Component | Version | Tag | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|-----------|---------|-----|--------|" >> "$GITHUB_STEP_SUMMARY"
# Process substitution (not pipe) so EXISTING_TAGS / NEW_TAGS
# mutations survive the loop: `jq | while ...` puts the loop
# body in a pipe subshell and discards the arrays on exit,
# which silently killed the aggregate summary block below.
while IFS= read -r row; do
_jq() { echo "$row" | base64 -d | jq -r "$1"; }
KEY=$(_jq '.key')
NAME=$(_jq '.name')
TAG_PATTERN=$(_jq '.tag_pattern')
# Skip components without tag patterns
if [ -z "$TAG_PATTERN" ] || [ "$TAG_PATTERN" = "null" ]; then
echo "Skipping $NAME - no tag pattern defined"
continue
fi
# Make script executable if needed
chmod +x scripts/extract-version.sh || true
VERSION=$(scripts/extract-version.sh "$KEY" 2>/dev/null || echo "ERROR")
TAG=$(scripts/extract-version.sh "$KEY" --tag 2>/dev/null || echo "ERROR")
# Consult --should-tag as the single source of truth for
# taggability. It handles SNAPSHOT and "no tag_pattern" in
# one place (extract-version.sh:348-360), which keeps this
# check in sync with the SDK matrix's own should_tag gate.
# A previous inline `[[ $VERSION =~ -SNAPSHOT$ ]]` check
# duplicated the rule and would silently drift when a new
# SDK added another SNAPSHOT-style pre-release marker.
SHOULD_TAG=$(scripts/extract-version.sh "$KEY" --should-tag 2>/dev/null || echo "false")
if [ "$SHOULD_TAG" = "false" ]; then
echo "ℹ️ $NAME: no tag will be created (SNAPSHOT or no tag_pattern)"
echo "| $NAME | $VERSION | _(none)_ | ℹ️ No tag (SNAPSHOT / no pattern) |" >> "$GITHUB_STEP_SUMMARY"
continue
fi
# In auto-publish mode, stable Docker versions only get :edge tag.
# Versioned git tags require manual publish (workflow_dispatch).
REGISTRY=$(_jq '.registry')
if [ "${{ inputs.create_edge_docker_tag }}" = "true" ] && [ "$REGISTRY" = "dockerhub" ]; then
if [[ ! "$VERSION" =~ -(edge|rc) ]]; then
echo "⏭️ $NAME: Stable Docker version in auto-publish mode, tag will be skipped"
echo "| $NAME | $VERSION | $TAG | ⏭️ Stable (manual publish only) |" >> "$GITHUB_STEP_SUMMARY"
continue
fi
fi
if [ "$VERSION" = "ERROR" ] || [ "$TAG" = "ERROR" ]; then
echo "❌ Failed to extract version/tag for $NAME"
echo "| $NAME | ERROR | ERROR | ❌ Failed to extract |" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
# Check if tag exists on the remote. Querying the REMOTE (not
# local `git rev-parse`) so this operator summary agrees with
# the authoritative view used by create-git-tag: a tag can exist
# on origin without being in this checkout, and the old local
# query hid exactly that case - you would see "will create" in
# the summary and then create-git-tag would refuse the push.
REMOTE_LINE=$(git ls-remote --tags origin "refs/tags/${TAG}" 2>/dev/null || true)
if [ -n "$REMOTE_LINE" ]; then
EXISTING_TAGS+=("$TAG")
REMOTE_PEELED=$(git ls-remote --tags origin "refs/tags/${TAG}^{}" 2>/dev/null | awk '{print $1}')
REMOTE_RAW=$(echo "$REMOTE_LINE" | awk '{print $1}')
EXISTING_SHA="${REMOTE_PEELED:-${REMOTE_RAW}}"
SHORT_SHA=$(echo "$EXISTING_SHA" | head -c 8)
# Auto-publish (create_edge_docker_tag=true) runs on every push
# to master, but component versions only change when a human
# bumps them. So the same immutable tag (e.g. server-0.8.1-edge.1)
# is re-seen on every run after the one that first created it.
# That is the expected steady state, not an error: the version
# is already released. The run still refreshes the rolling :edge
# Docker image; it just skips the versioned git tag / crate /
# :version manifest for that component. An existing tag here - at
# ANY commit - is therefore informational, never fatal.
# WRONG_TARGET_TAGS stays empty in this mode so the fail-fast
# exit 1 below cannot trigger.
if [ "${{ inputs.create_edge_docker_tag }}" = "true" ]; then
echo "ℹ️ $NAME: $TAG already exists at $SHORT_SHA - already released, run will refresh :edge only"
echo "| $NAME | $VERSION | $TAG | ℹ️ Already released ($SHORT_SHA) - :edge refresh only |" >> "$GITHUB_STEP_SUMMARY"
# Fail-fast on wrong-target for MANUAL publish. A wrong-target tag
# means create-git-tag would hard-fail 20-40 minutes later after
# publishing artifacts to crates.io / PyPI / npm / Maven /
# NuGet / DockerHub. Catching it at check-tags converts that
# into a fast, cheap failure at the top of the run.
# Same-target is still benign (rerun convergence).
#
# skip_tag_creation=true accepts the invariant hole (the
# operator opted out of tag writes) but the wrong-target
# state is still rendered loudly under a DISTINCT cell label,
# so an operator cannot mistake it for a benign same-target
# skip. Operators must reconcile the tag/registry divergence
# manually or run a follow-up without skip_tag_creation to
# converge. The fail-fast exit 1 below is gated on
# skip_tag_creation != true.
elif [ "$EXISTING_SHA" != "${{ needs.validate.outputs.commit }}" ]; then
WRONG_TARGET_TAGS+=("$TAG|$SHORT_SHA")
if [ "${{ inputs.skip_tag_creation }}" = "true" ]; then
echo "⚠️ Tag exists on remote at WRONG target: $TAG (points to $SHORT_SHA, not enforced: skip_tag_creation=true)"
echo "| $NAME | $VERSION | $TAG | ⚠️ Wrong target at $SHORT_SHA (NOT enforced) |" >> "$GITHUB_STEP_SUMMARY"
else
echo "❌ Tag exists on remote at wrong target: $TAG (points to $SHORT_SHA)"
echo "| $NAME | $VERSION | $TAG | ❌ Wrong target at $SHORT_SHA |" >> "$GITHUB_STEP_SUMMARY"
fi
else
echo "⚠️ Tag exists on remote at same target: $TAG (points to $SHORT_SHA)"
echo "| $NAME | $VERSION | $TAG | ⚠️ Exists at $SHORT_SHA (benign) |" >> "$GITHUB_STEP_SUMMARY"
fi
else
NEW_TAGS+=("$TAG")
echo "$NEW_TAG_LOG $TAG"
echo "| $NAME | $VERSION | $TAG | $NEW_TAG_CELL |" >> "$GITHUB_STEP_SUMMARY"
fi
done < <(echo "$TARGETS_JSON" | jq -r '.include[] | select(.key!="noop") | @base64')
echo "" >> "$GITHUB_STEP_SUMMARY"
# Summary
if [ ${#EXISTING_TAGS[@]} -gt 0 ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### ⚠️ Warning: Existing Tags Detected" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "The following tags already exist on the remote:" >> "$GITHUB_STEP_SUMMARY"
for tag in "${EXISTING_TAGS[@]}"; do
echo "- $tag" >> "$GITHUB_STEP_SUMMARY"
done
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ "${{ inputs.dry_run }}" = "false" ]; then
if [ "${{ inputs.skip_tag_creation }}" = "true" ]; then
echo "**Note:** Tag creation is disabled for this run." >> "$GITHUB_STEP_SUMMARY"
echo "Components will be (re)published, but no git tags will be pushed." >> "$GITHUB_STEP_SUMMARY"
else
echo "**Tag behavior with \`create-git-tag\` (SHA-match invariant):**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- If a pre-existing tag points at the SAME commit this run is publishing, the tag step is a no-op (benign skip). The artifact is (re)published; the tag stays." >> "$GITHUB_STEP_SUMMARY"
echo "- If a pre-existing tag points at a DIFFERENT commit, the tag step hard-fails with recovery instructions. The artifact publish still runs (registries are idempotent) but no tag is pushed." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "To recover from a wrong-target tag:" >> "$GITHUB_STEP_SUMMARY"
echo "1. Verify the intended release commit." >> "$GITHUB_STEP_SUMMARY"
echo "2. Delete the existing tag on origin: \`git push --delete origin <tag>\`" >> "$GITHUB_STEP_SUMMARY"
echo "3. Rerun this workflow." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "To republish without touching tags, set \`skip_tag_creation: true\` on the workflow dispatch." >> "$GITHUB_STEP_SUMMARY"
fi
fi
fi
if [ ${#NEW_TAGS[@]} -eq 0 ] && [ ${#EXISTING_TAGS[@]} -gt 0 ]; then
echo "### ℹ️ No New Tags to Create" >> "$GITHUB_STEP_SUMMARY"
echo "All specified components are already tagged at some commit. If tags match this run's commit, the rerun converges cleanly. If not, see the wrong-target recovery above or bump versions." >> "$GITHUB_STEP_SUMMARY"
elif [ ${#NEW_TAGS[@]} -gt 0 ]; then
if [ "${{ inputs.dry_run }}" = "true" ]; then
echo "### 🔍 Tags That Would Be Created (Dry Run)" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "The following tags would be created by a real run:" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
elif [ "${{ inputs.skip_tag_creation }}" = "true" ]; then
echo "### ℹ️ Tags That Would Be Created (Skipped)" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "The following tags would be created if tag creation wasn't disabled:" >> "$GITHUB_STEP_SUMMARY"
else
echo "### ✅ Tags to be Created" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
fi
for tag in "${NEW_TAGS[@]}"; do
echo "- $tag" >> "$GITHUB_STEP_SUMMARY"
done
fi
# Fail-fast on wrong-target tags. If any tag_pattern resolved to a
# name that already exists on origin at a commit different from
# this run's target, create-git-tag would hard-fail at tag push
# time anyway - but only after spending 20-40 min publishing
# artifacts. Catching it here converts that waste into a fast
# diagnostic at the top of the run. Kept as the LAST thing this
# step does so the operator summary block above is already
# populated before we exit.
if [ "${#WRONG_TARGET_TAGS[@]}" -gt 0 ]; then
{
echo ""
echo "### ❌ Wrong-target tags detected"
echo ""
echo "One or more tags already exist on the remote at a commit DIFFERENT from this run's target (\`${{ needs.validate.outputs.commit }}\`):"
echo ""
for entry in "${WRONG_TARGET_TAGS[@]}"; do
tag="${entry%|*}"
sha="${entry#*|}"
echo "- \`$tag\` currently points at \`$sha\`"
done
echo ""
echo "Failing fast to save 20-40 minutes of wasted publishing work; \`create-git-tag\` would hard-fail at tag push time anyway."
echo ""
echo "Recovery (verify the intended release commit first):"
echo "1. Delete the wrong tag(s) on origin: \`git push --delete origin <tag>\`"
echo "2. Or bump the version(s) and rerun the workflow"
echo "3. Or rerun with \`skip_tag_creation: true\` to republish artifacts only"
} >> "$GITHUB_STEP_SUMMARY"
echo "❌ Wrong-target tags detected: ${WRONG_TARGET_TAGS[*]}"
echo "See the Wrong-target tags block in the step summary for recovery steps."
if [ "${{ inputs.skip_tag_creation }}" != "true" ]; then
exit 1
fi
echo "ℹ️ skip_tag_creation=true, continuing despite wrong-target tags (operator opt-out)."
fi
build-python-wheels:
name: Build Python wheels
needs: [validate, plan, check-tags]
if: |
needs.validate.outputs.has_targets == 'true' &&
needs.plan.outputs.has_python == 'true'
uses: ./.github/workflows/_build_python_wheels.yml
with:
upload_artifacts: true
use_latest_ci: ${{ inputs.use_latest_ci }}
commit: ${{ needs.validate.outputs.commit }}
# Sequential Rust crate publishing to handle dependencies properly
publish-rust-crates:
name: Publish Rust crates
needs: [validate, plan, check-tags]
if: |
needs.validate.outputs.has_targets == 'true' &&
needs.plan.outputs.crates != ''
uses: ./.github/workflows/_publish_rust_crates.yml
permissions:
contents: write
id-token: write
with:
crates: ${{ needs.plan.outputs.crates }}
dry_run: ${{ inputs.dry_run }}
commit: ${{ needs.validate.outputs.commit }}
use_latest_ci: ${{ inputs.use_latest_ci }}
skip_tag_creation: ${{ inputs.skip_tag_creation }}
# Docker publishing on native runners (no QEMU emulation)
publish-docker:
name: Docker ${{ matrix.name }} (${{ matrix.arch }})
needs: [validate, plan, check-tags, build-python-wheels, publish-rust-crates]
if: |
!cancelled() &&
needs.check-tags.result == 'success' &&
needs.validate.outputs.has_targets == 'true' &&
needs.plan.outputs.has_docker == 'true' &&
fromJson(needs.plan.outputs.docker_matrix).include[0].key != 'noop' &&
(needs.build-python-wheels.result == 'success' || needs.build-python-wheels.result == 'skipped') &&
(needs.publish-rust-crates.result == 'success' || needs.publish-rust-crates.result == 'skipped')
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.docker_matrix) }}
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Download latest copy script from master
if: inputs.use_latest_ci
run: |
curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \
-o /tmp/copy-latest-from-master.sh
chmod +x /tmp/copy-latest-from-master.sh
echo "✅ Downloaded latest copy script from master"
- name: Checkout at commit
uses: actions/checkout@v7.0.1
with:
ref: ${{ needs.validate.outputs.commit }}
fetch-depth: 0
- name: Save and apply latest CI from master
if: inputs.use_latest_ci
env:
DOCKERFILE: ${{ matrix.dockerfile }}
run: |
/tmp/copy-latest-from-master.sh save \
.github \
scripts \
"$DOCKERFILE" \
"$DOCKERFILE.dockerignore"
/tmp/copy-latest-from-master.sh apply
- name: Ensure version extractor is executable
run: |
test -x scripts/extract-version.sh || chmod +x scripts/extract-version.sh
- name: Extract version
id: ver
run: |
VERSION=$(scripts/extract-version.sh "${{ matrix.key }}")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "✅ Resolved ${{ matrix.key }} -> version=$VERSION"
- name: Determine libc for component
id: libc
run: |
# Connectors runtime must use glibc because it dlopen()s glibc plugins
if [ "${{ matrix.key }}" = "rust-connectors" ]; then
echo "libc=glibc" >> "$GITHUB_OUTPUT"
else
echo "libc=musl" >> "$GITHUB_OUTPUT"
fi
- name: Publish Docker image
id: docker
uses: ./.github/actions/utils/docker-buildx
with:
task: publish
libc: ${{ steps.libc.outputs.libc }}
component: ${{ matrix.key }}
version: ${{ steps.ver.outputs.version }}
platform: ${{ matrix.platform }}
dry_run: ${{ inputs.dry_run }}
gha-cache: "false" # Use registry cache only to save GHA cache space
- name: Export digest
if: ${{ !inputs.dry_run }}
shell: bash
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.docker.outputs.digest }}"
if [ -n "$digest" ]; then
touch "${{ runner.temp }}/digests/${digest#sha256:}"
echo "Exported digest: $digest"
else
echo "::error::No digest available"
exit 1
fi
- name: Upload digest
if: ${{ !inputs.dry_run }}
uses: actions/upload-artifact@v7
with:
name: docker-digest-${{ matrix.key }}-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Create multi-arch Docker manifests after platform-specific images are pushed
docker-manifests:
name: Docker manifests
needs: [validate, plan, publish-docker]
if: |
!cancelled() &&
needs.validate.outputs.has_targets == 'true' &&
needs.plan.outputs.has_docker == 'true' &&
fromJson(needs.plan.outputs.docker_components).include[0].key != 'noop' &&
(needs.publish-docker.result == 'success' || needs.publish-docker.result == 'failure') &&
inputs.dry_run == false
runs-on: ubuntu-26.04
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.plan.outputs.docker_components) }}
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Download latest copy script from master
if: inputs.use_latest_ci
run: |
curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \
-o /tmp/copy-latest-from-master.sh
chmod +x /tmp/copy-latest-from-master.sh
echo "✅ Downloaded latest copy script from master"
- uses: actions/checkout@v7.0.1
with:
ref: ${{ needs.validate.outputs.commit }}
# create-git-tag's shallow-safe fallback at action.yml:86-96
# will `git fetch --no-tags --depth=1 origin <commit>` if the
# commit is not in the local clone. GitHub allows single-commit
# fetches via allowReachableSHA1InWant=true, so fetch-depth:1
# here is enough - no need to pay for full history.
fetch-depth: 1
- name: Save and apply latest CI from master
if: inputs.use_latest_ci
run: |
/tmp/copy-latest-from-master.sh save \
.github \
scripts
/tmp/copy-latest-from-master.sh apply
- name: Ensure version extractor is executable
run: |
test -x scripts/extract-version.sh || chmod +x scripts/extract-version.sh
- name: Extract version & tag
id: ver
shell: bash
env:
MATRIX_KEY: ${{ matrix.key }}
MATRIX_TAG_PATTERN: ${{ matrix.tag_pattern }}
CREATE_EDGE_DOCKER_TAG: ${{ inputs.create_edge_docker_tag }}
run: |
set -euo pipefail
VERSION=$(scripts/extract-version.sh "$MATRIX_KEY")
TAG=""
if [ -n "$MATRIX_TAG_PATTERN" ] && [ "$MATRIX_TAG_PATTERN" != "null" ]; then
TAG=$(scripts/extract-version.sh "$MATRIX_KEY" --tag)
fi
# Base should_tag rule (SNAPSHOT + tag_pattern presence) is owned by
# extract-version.sh so it stays in sync with the SDK matrix below.
# The Docker-only auto-publish stable-skip rule layers on top: in
# auto-publish mode (create_edge_docker_tag=true) stable versions
# only get the rolling :edge tag, never a versioned git tag. This
# mirrors the manifest push policy a few steps below.
SHOULD_TAG=$(scripts/extract-version.sh "$MATRIX_KEY" --should-tag)
if [ "$SHOULD_TAG" = "true" ] \
&& [ "$CREATE_EDGE_DOCKER_TAG" = "true" ] \
&& [ "$(scripts/extract-version.sh "$MATRIX_KEY" --is-pre-release)" != "true" ]; then
SHOULD_TAG=false
fi
# Git tags are immutable. A component version is bumped by a human;
# auto-publish then re-runs on every subsequent master push with that
# same version until the next bump. Once the tag exists the versioned
# release (git tag + :version manifest) is already done - this run
# only refreshes the rolling :edge image. Querying the remote (not the
# shallow local clone) so this agrees with create-git-tag's own
# authoritative ls-remote check.
if [ "$SHOULD_TAG" = "true" ] && [ -n "$TAG" ] \
&& git ls-remote --tags --exit-code origin "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "ℹ️ $TAG already exists on remote - versioned release already done, refreshing :edge only"
SHOULD_TAG=false
fi
{
echo "version=$VERSION"
echo "tag=$TAG"
echo "should_tag=$SHOULD_TAG"
} >> "$GITHUB_OUTPUT"
echo "✅ Resolved $MATRIX_KEY -> version=$VERSION tag=${TAG:-<none>} should_tag=$SHOULD_TAG"
- name: Download amd64 digest
uses: actions/download-artifact@v8
with:
name: docker-digest-${{ matrix.key }}-amd64
path: ${{ runner.temp }}/digests
- name: Download arm64 digest
uses: actions/download-artifact@v8
with:
name: docker-digest-${{ matrix.key }}-arm64
path: ${{ runner.temp }}/digests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to Docker Hub
uses: ./.github/actions/utils/docker-login
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Create and push manifest
working-directory: ${{ runner.temp }}/digests
env:
IMAGE: ${{ matrix.image }}
SHOULD_TAG: ${{ steps.ver.outputs.should_tag }}
run: |
VERSION="${{ steps.ver.outputs.version }}"
echo "Creating manifests for $IMAGE from digests:"
ls -la
if [ "${{ inputs.create_edge_docker_tag }}" = "true" ]; then
# Auto-publish: :edge is the rolling tag and is ALWAYS refreshed.
docker buildx imagetools create \
-t "${IMAGE}:edge" \
$(printf "${IMAGE}@sha256:%s " *)
echo "✅ Pushed manifest: ${IMAGE}:edge"
# The versioned :version manifest is part of the immutable release
# and ships only alongside a new git tag. should_tag is false when
# the version is stable (versioned release is manual-only) OR when
# the tag already exists (already released). In both cases this run
# refreshes :edge and nothing else.
if [ "$SHOULD_TAG" = "true" ]; then
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
$(printf "${IMAGE}@sha256:%s " *)
echo "✅ Pushed manifest: ${IMAGE}:${VERSION}"
else
echo "ℹ️ should_tag=false - :edge refreshed, skipping versioned :${VERSION} manifest"
fi
else
# Manual publish: always push the versioned manifest, plus :latest
# for stable (non edge/rc) releases.
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
$(printf "${IMAGE}@sha256:%s " *)
echo "✅ Pushed manifest: ${IMAGE}:${VERSION}"
if [[ ! "$VERSION" =~ -(edge|rc) ]]; then
echo "Creating 'latest' manifest"
docker buildx imagetools create \
-t "${IMAGE}:latest" \
$(printf "${IMAGE}@sha256:%s " *)
echo "✅ Pushed manifest: ${IMAGE}:latest"
fi
fi
- name: Inspect manifest
env:
IMAGE: ${{ matrix.image }}
SHOULD_TAG: ${{ steps.ver.outputs.should_tag }}
run: |
VERSION="${{ steps.ver.outputs.version }}"
# Auto-publish pushed :version only when should_tag was true; in
# every other auto-publish case (:edge-only) inspect :edge instead.
if [ "${{ inputs.create_edge_docker_tag }}" = "true" ] && [ "$SHOULD_TAG" != "true" ]; then
echo "Inspecting :edge manifest (versioned manifest was skipped: should_tag=false)"
docker buildx imagetools inspect "${IMAGE}:edge"
else
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
fi
# Inline per-component tagging: tightly couple the git tag to the
# multi-arch manifest that just shipped. should_tag was computed in the
# version step above and already encodes the SNAPSHOT and auto-publish
# stable-Docker skip rules. dry_run is gated at the job level.
- name: Tag Docker release (${{ matrix.key }})
if: |
success() &&
inputs.skip_tag_creation == false &&
steps.ver.outputs.should_tag == 'true'
uses: ./.github/actions/utils/create-git-tag
with:
tag: ${{ steps.ver.outputs.tag }}
commit: ${{ needs.validate.outputs.commit }}
message: |
Release ${{ matrix.key }} ${{ steps.ver.outputs.version }}
Component: ${{ matrix.key }}
Tag: ${{ steps.ver.outputs.tag }}
Commit: ${{ needs.validate.outputs.commit }}
Released by: GitHub Actions (workflow ${{ github.run_id }})
# Non-Docker, non-Rust publishing (Python, Node, Java, C#, Go SDKs)
# Note: This job runs in parallel with Docker publishing - no dependency between them
publish:
name: ${{ matrix.name }}
needs: [validate, plan, check-tags, build-python-wheels, publish-rust-crates]
if: |
!cancelled() &&
needs.check-tags.result == 'success' &&
needs.validate.outputs.has_targets == 'true' &&
fromJson(needs.plan.outputs.non_docker_targets).include[0].key != 'noop' &&
(needs.build-python-wheels.result == 'success' || needs.build-python-wheels.result == 'skipped') &&