forked from MekayelAnik/ispyagentdvr-docker
-
Notifications
You must be signed in to change notification settings - Fork 4
2488 lines (2216 loc) · 113 KB
/
Copy pathdocker-build.yml
File metadata and controls
2488 lines (2216 loc) · 113 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
# =============================================================================
# iSpy Agent DVR Docker Image Builder
# =============================================================================
# Consolidated workflow for automated and manual Docker image builds
#
# Features:
# - Automatic release detection (every 30 minutes - time-based)
# - Multi-platform builds (amd64, arm64, arm/v7)
# - Dual registry support (Docker Hub + ghcr.io)
# - Registry synchronization
# - Manual version builds (range or comma-separated)
# - Image promotion (latest/stable tags)
# - 5-day stable promotion
# - ZSTD compression + OCI media types
# - Vulnerability scanning (non-blocking)
# - Build provenance and SBOM
# - Version validation with warnings (missing versions don't fail build)
#
# Required Secrets:
# - DOCKERHUB_USERNAME: Docker Hub username
# - DOCKERHUB_TOKEN: Docker Hub access token (with push permissions)
#
# Note: GITHUB_TOKEN is automatically provided and has packages:write scope
#
# =============================================================================
name: Docker Build & Publish
on:
# =========================================================================
# EXTERNAL TRIGGER: For reliable scheduling via cron-job.org or similar
# =========================================================================
repository_dispatch:
types: [Docker Build & Publish - Cron Scheduled, release-published]
# =========================================================================
# DAILY SAFETY NET: catches anything the release-published dispatch missed
# (failed dispatch, token problems), ages :latest into :stable after 5 days,
# and picks up package-refresh rebuilds. GitHub cron can run minutes late;
# that's fine for a daily sweep.
# =========================================================================
schedule:
- cron: '17 3 * * *'
# =========================================================================
# MANUAL TRIGGER: With detailed input options
# =========================================================================
workflow_dispatch:
inputs:
# --- Action Selection ---
action:
description: 'Select the action to perform'
required: true
type: choice
options:
- 'auto-check'
- 'build-versions'
- 'promote-image'
- 'force-promote-latest'
- 'promote-stable'
- 'force-promote-stable'
- 'sync-registries'
default: 'auto-check'
# --- Version Build Options ---
versions:
description: |
Versions to build (build-versions) or promote (force-promote-latest).
Minimum buildable version: 7.9.4.0 (older releases lack bundled FFmpeg; see MIN_BUILD_VERSION)
Examples:
- Single: 7.9.4.0
- Multiple: 7.9.4.0,7.9.5.0
- Range: 7.9.4.0-7.9.6.0
required: false
type: string
default: ''
# --- Base Image Selection ---
base_image:
description: 'Base image for Docker build'
required: false
type: choice
options:
- 'default (latest)'
- 'custom'
default: 'default (latest)'
custom_base_image:
description: 'Custom base image URL (only when base_image=custom)'
required: false
type: string
default: ''
# --- Image Promotion Options ---
promote_version:
description: 'Source version to promote (for promote-image action)'
required: false
type: string
default: ''
promote_tag:
description: 'Target tag for promotion'
required: false
type: choice
options:
- 'latest'
- 'stable'
- 'beta'
default: 'latest'
# --- Build Behavior Options (Checkboxes) ---
force_build:
description: 'Force rebuild even if image already exists in registries'
required: false
type: boolean
default: false
skip_existing:
description: 'Skip versions that already exist in registries'
required: false
type: boolean
default: true
tag_as_latest:
description: 'Also tag the built image(s) as latest'
required: false
type: boolean
default: true
run_security_scan:
description: 'Run Trivy security vulnerability scan'
required: false
type: boolean
default: true
push_to_dockerhub:
description: 'Push to Docker Hub registry'
required: false
type: boolean
default: true
push_to_ghcr:
description: 'Push to GitHub Container Registry (ghcr.io)'
required: false
type: boolean
default: true
# --- Base Image Update Options ---
update_base_image:
description: 'Rebuild if base image has updates (checks digest)'
required: false
type: boolean
default: false
# --- Platform Options ---
platforms:
description: 'Target platforms for multi-arch build'
required: false
type: choice
options:
- 'linux/amd64,linux/arm64,linux/arm/v7'
- 'linux/amd64,linux/arm64'
- 'linux/amd64'
default: 'linux/amd64,linux/arm64,linux/arm/v7'
# --- Compression Options ---
compression_level:
description: 'ZSTD compression level (1-22, higher = smaller but slower)'
required: false
type: choice
options:
- '22'
- '19'
- '15'
- '10'
- '5'
- '3'
default: '22'
# Cancel in-progress runs for the same trigger/action context.
# This avoids unrelated manual actions sharing the same concurrency key.
concurrency:
group: docker-build-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.action || 'auto-check' }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.versions || 'default' }}
cancel-in-progress: false
env:
# Configuration sourced from repository variables (Settings → Secrets and variables → Actions → Variables)
# Fallback values used if variables are not set
TZ: ${{ vars.TZ || 'UTC' }}
DOCKERHUB_REPO: ${{ vars.DOCKERHUB_REPO || 'ispysoftware/agentdvr' }}
GHCR_REPO: ${{ vars.GHCR_REPO || 'ghcr.io/ispysoftware/agentdvr' }}
DEFAULT_PLATFORMS: ${{ vars.DEFAULT_PLATFORMS || 'linux/amd64,linux/arm64,linux/arm/v7' }}
BINARY_BASE_URL: ${{ vars.BINARY_BASE_URL || 'https://files.ispyconnect.com/downloads' }}
RELEASE_URL: ${{ vars.RELEASE_URL || 'https://www.ispyconnect.com/producthistory?productid=27' }}
# Primary version source: reads dbo.Products directly with no origin or edge caching,
# so a release-published dispatch fired seconds after UpdateVersion sees the new
# version immediately. The download-API URLs below are the (5-minute-cached) fallback.
LIVE_VERSION_URL: ${{ vars.LIVE_VERSION_URL || 'https://www.ispyconnect.com/liveversion?productid=27' }}
# Beta channel lives outside the release-history page: iSpy stopped tagging betas with
# "(Beta):" there after 7.7.5.0 (24 Jul 2026) and now only exposes them via this API
# (same endpoint the official linux_setup2.sh uses for USE_BETA=true).
BETA_API_URL: ${{ vars.BETA_API_URL || 'https://www.ispyconnect.com/api/Agent/DownloadLocation5?platform=Linux64&useVersion=0&useBeta=True' }}
# Same endpoint on the stable channel. Used as a safety net: if the release-history
# page moves or changes markup again, this still yields the current release so the
# newest version never goes unbuilt (a page redirect cost 3 weeks of builds in Jun 2026).
STABLE_API_URL: ${{ vars.STABLE_API_URL || 'https://www.ispyconnect.com/api/Agent/DownloadLocation5?platform=Linux64&useVersion=0&useBeta=False' }}
# NOTE: deliberately does NOT read github.event.client_payload.force_build.
# The scheduled cron payload has carried force_build:"true" for months while
# github.event.inputs was empty for repository_dispatch, so it never took effect.
# Honouring it turned every 30-minute poll into a full multi-arch rebuild.
# Use the package_refresh knobs below for scheduled rebuilds, or workflow_dispatch
# for a genuine one-off force.
FORCE_BUILD: ${{ github.event.inputs.force_build || 'false' }}
# Rebuild the latest version once its published image ages past this, so Debian
# package updates in the base image actually reach the shipped image.
# Resolution order: client_payload > repo variable > default. Resolved in shell,
# since `x != null && x || y` collapses an explicit `false` back to `y`.
#
# {"event_type":"Docker Build & Publish - Cron Scheduled",
# "client_payload":{"package_refresh":false,"max_image_age_days":14}}
CP_PACKAGE_REFRESH: ${{ github.event.client_payload.package_refresh }}
CP_MAX_IMAGE_AGE_DAYS: ${{ github.event.client_payload.max_image_age_days }}
VAR_PACKAGE_REFRESH: ${{ vars.PACKAGE_REFRESH_ENABLED }}
VAR_MAX_IMAGE_AGE_DAYS: ${{ vars.MAX_IMAGE_AGE_DAYS }}
DEFAULT_BASE_IMAGE: ${{ vars.DEFAULT_BASE_IMAGE || 'ispysoftware/agentdvr-base-image:latest' }}
# Base image update check: set to 'true' to enable automatic rebuilds when base image changes
UPDATE_BASE_IMAGE_ENABLED: ${{ vars.UPDATE_BASE_IMAGE_ENABLED || 'false' }}
# Comma-separated list of versions to exclude from builds (e.g. "7.0.5.0,7.0.6.0")
EXCLUDE_VERSIONS: ${{ vars.EXCLUDE_VERSIONS || '' }}
# Oldest version this pipeline may build. Releases before 7.9.4.0 do not bundle
# FFmpeg, and the org base image no longer provides one — building them would
# produce images with no FFmpeg at all. Applied at the source (the release list),
# so backfills, manual build requests and stable promotion all respect it.
MIN_BUILD_VERSION: ${{ vars.MIN_BUILD_VERSION || '7.9.4.0' }}
# How long the latest version must remain current before stable promotion (e.g. "5d", "7d", "30d", "12h")
STABLE_PROMOTION_AGE: ${{ vars.STABLE_PROMOTION_AGE || '5d' }}
# Number of recent upstream versions to scan for missing builds
VERSION_CHECK_COUNT: ${{ vars.VERSION_CHECK_COUNT || '10' }}
# BuildKit concurrency and log tuning
EXPORT_CACHE_CONCURRENCY: '4'
EXPORT_LAYERS_CONCURRENCY: '4'
BUILDKIT_STEP_LOG_MAX_SIZE: '50000000'
BUILDKIT_STEP_LOG_MAX_SPEED: '100000000'
BUILDKIT_PROGRESS: 'plain'
permissions:
contents: write
packages: write
security-events: write
jobs:
# ===========================================================================
# Job 0: Quick state check — skip pipeline if nothing changed
# ===========================================================================
quick-check:
timeout-minutes: 15
name: Quick state check
runs-on: ${{ vars.ACTION_RUNNER_VERSION || 'ubuntu-latest' }}
outputs:
should_continue: ${{ steps.state.outputs.should_continue }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Check pipeline state
id: state
env:
RELEASE_URL: ${{ env.RELEASE_URL }}
run: |
FORCE="${{ env.FORCE_BUILD }}"
ACTION="${{ github.event.inputs.action || 'auto-check' }}"
# Non-auto-check actions always continue
if [[ "$ACTION" != "auto-check" ]]; then
echo "should_continue=true" >> "$GITHUB_OUTPUT"
echo "Action '$ACTION' always runs"
exit 0
fi
# Force build always continues
if [[ "$FORCE" == "true" ]]; then
echo "should_continue=true" >> "$GITHUB_OUTPUT"
echo "Force build requested"
exit 0
fi
# Fetch current latest version from upstream (lightweight curl + grep)
CURRENT_VERSION=""
if CURRENT_VERSION=$(curl -sfL "$RELEASE_URL" | grep -oP '<td[^>]*>\d+\.\d+\.\d+\.\d+</td>' | head -1 | sed 's/<[^>]*>//g'); then
echo "Current upstream version: $CURRENT_VERSION"
else
echo "::warning::Could not fetch upstream version -- continuing pipeline"
echo "should_continue=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Always continue -- auto-check now scans all 20 versions for missing builds,
# not just the latest. The full registry check happens in check-releases.
echo "should_continue=true" >> "$GITHUB_OUTPUT"
echo "Current upstream latest: $CURRENT_VERSION -- continuing to full scan"
# ===========================================================================
# Job 1: Check for new releases and validate requested versions
# ===========================================================================
check-releases:
timeout-minutes: 15
name: Check Releases & Validate Versions
needs: quick-check
if: needs.quick-check.outputs.should_continue == 'true'
runs-on: ${{ vars.ACTION_RUNNER_VERSION || 'ubuntu-latest' }}
outputs:
versions_to_build: ${{ steps.filter-excluded.outputs.versions || steps.determine-versions.outputs.versions }}
versions_json: ${{ steps.filter-excluded.outputs.versions_json || steps.determine-versions.outputs.versions_json }}
latest_version: ${{ steps.fetch-releases.outputs.latest_version }}
latest_is_beta: ${{ steps.fetch-releases.outputs.latest_is_beta }}
latest_beta_version: ${{ steps.fetch-releases.outputs.latest_beta_version }}
beta_versions: ${{ steps.fetch-releases.outputs.beta_versions }}
available_versions: ${{ steps.fetch-releases.outputs.available_versions }}
should_build: ${{ steps.filter-excluded.outputs.should_build || steps.determine-versions.outputs.should_build }}
should_mark_stable: ${{ steps.check-stable.outputs.should_mark_stable }}
stable_version: ${{ steps.check-stable.outputs.stable_version }}
action_type: ${{ steps.set-action.outputs.action }}
missing_versions: ${{ steps.validate-versions.outputs.missing_versions }}
base_image_selection: ${{ steps.resolve-base-image.outputs.selection }}
custom_base_image: ${{ steps.resolve-base-image.outputs.custom }}
base_image_updated: ${{ steps.check-base-image.outputs.updated }}
platforms: ${{ steps.set-platforms.outputs.platforms }}
compression_level: ${{ steps.set-compression.outputs.level }}
build_matrix: ${{ steps.generate-matrix.outputs.matrix }}
latest_needs_promote: ${{ steps.determine-versions.outputs.latest_needs_promote }}
stable_needs_promote: ${{ steps.determine-versions.outputs.stable_needs_promote }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set action type
id: set-action
run: |
if [ "${{ github.event_name }}" == "repository_dispatch" ] || [ "${{ github.event_name }}" == "schedule" ]; then
echo "action=auto-check" >> $GITHUB_OUTPUT
echo "Running scheduled auto-check (trigger: ${{ github.event_name }})..."
else
ACTION="${{ github.event.inputs.action }}"
VERSIONS_INPUT="${{ github.event.inputs.versions }}"
# If specific versions are passed with the default auto-check action,
# honor them: build exactly those instead of sweeping the last N
# releases. Prevents an unintended mass rebuild when a version is
# typed but the action dropdown is left on auto-check.
if [ "$ACTION" == "auto-check" ] && [ -n "$VERSIONS_INPUT" ]; then
echo "versions input ('$VERSIONS_INPUT') provided with auto-check -- routing to build-versions"
ACTION="build-versions"
fi
echo "action=$ACTION" >> $GITHUB_OUTPUT
echo "Running manual action: $ACTION"
fi
- name: Set platforms
id: set-platforms
run: |
if [ -n "${{ github.event.inputs.platforms }}" ]; then
echo "platforms=${{ github.event.inputs.platforms }}" >> $GITHUB_OUTPUT
else
echo "platforms=${{ env.DEFAULT_PLATFORMS }}" >> $GITHUB_OUTPUT
fi
- name: Set compression level
id: set-compression
run: |
# Default to level 22 (maximum compression) if not specified
if [ -n "${{ github.event.inputs.compression_level }}" ]; then
echo "level=${{ github.event.inputs.compression_level }}" >> $GITHUB_OUTPUT
else
echo "level=22" >> $GITHUB_OUTPUT
fi
echo "ZSTD compression level: ${{ github.event.inputs.compression_level || '22' }}"
- name: Resolve base image selection
id: resolve-base-image
run: |
INPUT_BASE="${{ github.event.inputs.base_image }}"
CUSTOM_BASE="${{ github.event.inputs.custom_base_image }}"
# Output the selection key (not the resolved URL to avoid secret masking)
# The actual URL will be resolved in each build job
if [ -z "$INPUT_BASE" ] || [ "$INPUT_BASE" == "default (latest)" ]; then
echo "selection=default" >> $GITHUB_OUTPUT
else
echo "selection=$INPUT_BASE" >> $GITHUB_OUTPUT
fi
# Pass custom base image if specified
if [ "$INPUT_BASE" == "custom" ] && [ -n "$CUSTOM_BASE" ]; then
echo "custom=$CUSTOM_BASE" >> $GITHUB_OUTPUT
else
echo "custom=" >> $GITHUB_OUTPUT
fi
echo "Base image selection: ${INPUT_BASE:-default}"
- name: Install dependencies
run: |
# jq and curl ship with the runner image; only skopeo needs apt, and it is
# merely a fallback for crane -- a stalled mirror here must not hold the
# concurrency group. A hung apt once blocked this job for hours.
sudo rm -f /etc/apt/sources.list.d/google-chrome.list
if ! sudo timeout 120 apt-get update -qq; then
echo "::warning::apt-get update failed -- continuing with crane only"
exit 0
fi
sudo timeout 180 apt-get install -y -qq skopeo \
|| echo "::warning::skopeo unavailable -- crane only"
- name: Setup build environment
uses: ./.github/actions/setup-build-env
with:
do_checkout: 'false'
setup_qemu: 'false'
setup_buildx: 'false'
login_dockerhub: 'true'
login_ghcr: 'true'
dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }}
ghcr_actor: ${{ github.actor }}
ghcr_token: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch and parse release history
id: fetch-releases
run: |
echo "Fetching release history from iSpy..."
# Fetch the page (follow redirects; iSpy dropped .aspx URLs with a 301 in Jun 2026)
curl -sfL "${{ env.RELEASE_URL }}" -o /tmp/releases.html
# Parse the HTML table to extract versions and dates
# Note: HTML uses <td valign="top"> so we match full td tag and strip with sed
VERSION_LIMIT="${{ env.VERSION_CHECK_COUNT }}"
grep -oP '<td[^>]*>\d+\.\d+\.\d+\.\d+</td>' /tmp/releases.html | sed 's/<[^>]*>//g' | head -"$VERSION_LIMIT" > /tmp/versions.txt
# Resolve both release channels. Primary source is /liveversion, which reads
# the database directly with no origin or edge caching — a release-published
# dispatch fires seconds after UpdateVersion, inside the 5-minute window every
# other version surface caches on, so only this endpoint is guaranteed fresh.
# Falls back to the download API (5-minute-aligned cache) when unavailable.
STABLE_CHANNEL_VERSION=""
BETA_CHANNEL_VERSION=""
if LIVE_JSON=$(curl -sfL --max-time 20 "${{ env.LIVE_VERSION_URL }}"); then
STABLE_CHANNEL_VERSION=$(echo "$LIVE_JSON" | jq -r '.version // empty' 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || true)
BETA_CHANNEL_VERSION=$(echo "$LIVE_JSON" | jq -r '.betaVersion // empty' 2>/dev/null | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || true)
fi
# Fallback: the URLs iSpy's own installer uses, so they answer even when the
# release-history page does not. Prints e.g. .../Agent_Linux64_7_9_3_0.zip.
# Returns empty on any curl failure (4xx/5xx/network) so callers skip the splice.
# Quotes are stripped in-shell rather than by a pipe, so curl's exit status is
# the command's exit status and the failure branch actually fires.
channel_version() {
local url=$1 dl=""
dl=$(curl -sfL "$url") || return 0
echo "${dl//\"/}" | sed -nE 's#.*_([0-9]+)_([0-9]+)_([0-9]+)_([0-9]+)\.zip$#\1.\2.\3.\4#p'
}
if [ -z "$STABLE_CHANNEL_VERSION" ]; then
echo "::warning::/liveversion unavailable or unparseable -- falling back to the download API (5-minute aligned cache)"
# Bridge for sites without /liveversion: a release-published dispatch fires
# seconds after UpdateVersion, inside the stale window of the download API's
# cache. That cache expires at 5-minute wall-clock boundaries, so waiting
# just past the next boundary guarantees a fresh read. Skipped entirely
# once /liveversion answers above.
if [ "${{ github.event.action }}" == "release-published" ]; then
NOW=$(date +%s)
WAIT=$(( 300 - NOW % 300 + 15 ))
echo "release-published dispatch: waiting ${WAIT}s for the aligned caches to roll over"
sleep "$WAIT"
fi
STABLE_CHANNEL_VERSION=$(channel_version "${{ env.STABLE_API_URL }}")
BETA_CHANNEL_VERSION=$(channel_version "${{ env.BETA_API_URL }}")
fi
echo "Version channels -- stable: ${STABLE_CHANNEL_VERSION:-<unresolved>}, beta: ${BETA_CHANNEL_VERSION:-<unresolved>}"
# Hard-fail only when every source is dead. A silently empty version list
# disabled all builds for 3 weeks when the release URL started redirecting;
# now a broken page degrades to API-only instead of stopping the pipeline.
if [ ! -s /tmp/versions.txt ]; then
if [ -z "$STABLE_CHANNEL_VERSION" ] && [ -z "$BETA_CHANNEL_VERSION" ]; then
echo "::error::No versions parsed from $RELEASE_URL and the download API returned nothing"
exit 1
fi
echo "::warning::No versions parsed from $RELEASE_URL -- page moved or markup changed. Falling back to the download API alone; backfill scanning and :stable promotion are degraded until the page parses again."
fi
grep -oP '<td[^>]*>\d{1,2}/\d{1,2}/\d{4}</td>' /tmp/releases.html | sed 's/<[^>]*>//g' | head -"$VERSION_LIMIT" > /tmp/dates.txt
# Parse beta status: HTML spans multiple lines, so convert to single-line first
# Then extract each row and check if update info contains (Beta):
tr '\n' ' ' < /tmp/releases.html | sed 's/ */ /g' > /tmp/releases_oneline.html
> /tmp/beta_status.txt
while IFS= read -r version; do
# Extract the row for this version and check for (Beta): in the update info
ROW=$(grep -oP "<tr><td[^>]*>${version}</td><td[^>]*>.*?</td><td[^>]*>\d{1,2}/\d{1,2}/\d{4}</td></tr>" /tmp/releases_oneline.html | head -1)
if echo "$ROW" | grep -q '(Beta):'; then
echo "$version|true" >> /tmp/beta_status.txt
else
echo "$version|false" >> /tmp/beta_status.txt
fi
done < /tmp/versions.txt
# Preserve the release-history view of "newest" before splicing anything in.
HISTORY_LATEST_IS_BETA=$(head -1 /tmp/beta_status.txt | cut -d'|' -f2)
# --- Splice in versions the release-history page does not list ---
# iSpy stopped marking betas with "(Beta):" on that page after 7.7.5.0 (24 Jul 2026),
# and the page can lag or break for stable releases too. Anything the download API
# reports but the page omits is prepended as the newest entry so it still gets built.
# Date is "-" (unknown): these entries never feed the :stable promotion clock.
splice_version() {
local version=$1 is_beta=$2 label=$3
if [ -z "$version" ]; then
echo "::warning::Could not resolve a $label version from the download API"
return 0
fi
if grep -qx "$version" /tmp/versions.txt; then
echo "$label channel version $version already in release history -- no splice"
return 0
fi
echo "$label channel exposes $version (absent from release history) -- splicing in"
# Prepend via a temp file: "sed -i 1i" is a no-op on an empty file, which is
# exactly the case here when the release-history page failed to parse.
{ echo "$version"; cat /tmp/versions.txt; } > /tmp/versions.new && mv /tmp/versions.new /tmp/versions.txt
{ echo '-'; cat /tmp/dates.txt; } > /tmp/dates.new && mv /tmp/dates.new /tmp/dates.txt
{ echo "${version}|${is_beta}"; cat /tmp/beta_status.txt; } > /tmp/beta_status.new && mv /tmp/beta_status.new /tmp/beta_status.txt
}
# Stable first so that, if both splice, the beta still ends up on top.
splice_version "$STABLE_CHANNEL_VERSION" "false" "Stable"
splice_version "$BETA_CHANNEL_VERSION" "true" "Beta"
# Enforce MIN_BUILD_VERSION: drop anything older from the release list so no
# downstream path (auto-check backfill, build-versions validation, stable
# promotion) can select a version the current base image cannot support.
# The three files are line-aligned, so they are filtered by the same indices.
MIN_VER="${{ env.MIN_BUILD_VERSION }}"
if [ -n "$MIN_VER" ]; then
: > /tmp/versions.new; : > /tmp/dates.new; : > /tmp/beta_status.new
i=0
while IFS= read -r v; do
i=$((i+1))
if [ "$(printf '%s\n%s\n' "$v" "$MIN_VER" | sort -V | head -1)" = "$MIN_VER" ]; then
echo "$v" >> /tmp/versions.new
sed -n "${i}p" /tmp/dates.txt >> /tmp/dates.new
sed -n "${i}p" /tmp/beta_status.txt >> /tmp/beta_status.new
else
echo "Skipping $v -- below version floor $MIN_VER (no bundled FFmpeg)"
fi
done < /tmp/versions.txt
mv /tmp/versions.new /tmp/versions.txt
mv /tmp/dates.new /tmp/dates.txt
mv /tmp/beta_status.new /tmp/beta_status.txt
if [ ! -s /tmp/versions.txt ]; then
echo "::warning::No versions at or above the $MIN_VER floor -- nothing is eligible to build yet"
fi
fi
# Create combined output (version|date|is_beta)
paste -d'|' /tmp/versions.txt /tmp/dates.txt > /tmp/releases_temp.txt
paste -d'|' /tmp/releases_temp.txt <(cut -d'|' -f2 /tmp/beta_status.txt) > /tmp/releases.txt
# Get latest version and its beta status
LATEST_VERSION=$(awk -F'|' '$3 != "true" { print $1; exit }' /tmp/releases.txt)
LATEST_IS_BETA="$HISTORY_LATEST_IS_BETA"
echo "latest_version=$LATEST_VERSION" >> $GITHUB_OUTPUT
echo "latest_is_beta=$LATEST_IS_BETA" >> $GITHUB_OUTPUT
# :beta tracks a beta only while it is actually ahead of stable. Once a beta is
# promoted, the newest "(Beta):" row still inside the scan window is historical --
# letting it stay the rolling target would drag :beta backwards if registry drift
# ever marked that old version for rebuild.
LATEST_BETA_VERSION=$(grep '|true$' /tmp/beta_status.txt | head -1 | cut -d'|' -f1)
if [ -n "$LATEST_BETA_VERSION" ] && [ -n "$LATEST_VERSION" ] && \
[ "$(printf '%s\n%s\n' "$LATEST_BETA_VERSION" "$LATEST_VERSION" | sort -V | tail -1)" = "$LATEST_VERSION" ]; then
echo "Newest beta $LATEST_BETA_VERSION is not ahead of stable $LATEST_VERSION -- no live beta channel"
LATEST_BETA_VERSION=""
fi
echo "latest_beta_version=$LATEST_BETA_VERSION" >> $GITHUB_OUTPUT
# Create JSON object mapping version -> beta status
BETA_VERSIONS=$(awk -F'|' '{printf "\"%s\":%s,", $1, $2}' /tmp/beta_status.txt | sed 's/,$//' | sed 's/^/{/' | sed 's/$/}/')
echo "beta_versions=$BETA_VERSIONS" >> $GITHUB_OUTPUT
# Create JSON array of available versions
AVAILABLE_VERSIONS=$(cat /tmp/versions.txt | jq -R -s -c 'split("\n") | map(select(length > 0))')
echo "available_versions=$AVAILABLE_VERSIONS" >> $GITHUB_OUTPUT
echo "Latest version: $LATEST_VERSION (beta: $LATEST_IS_BETA)"
echo "Found $(wc -l < /tmp/versions.txt) versions in release history"
echo "Beta versions:"
grep '|true$' /tmp/beta_status.txt | cut -d'|' -f1 || echo " (none in top 20)"
# Save for other steps
cp /tmp/versions.txt /tmp/available_versions.txt
cp /tmp/beta_status.txt /tmp/beta_status_available.txt
- name: Validate requested versions against available versions
id: validate-versions
run: |
INPUT_VERSIONS="${{ github.event.inputs.versions }}"
AVAILABLE_FILE="/tmp/available_versions.txt"
# Function to check if version exists
version_exists() {
grep -q "^$1$" "$AVAILABLE_FILE"
}
# Function to expand version range (bidirectional - handles both orderings)
expand_range() {
local start=$1
local end=$2
local found_versions=""
local in_range=false
local start_line=0
local end_line=0
local line_num=0
# First pass: find line numbers of start and end versions
while IFS= read -r version; do
line_num=$((line_num + 1))
if [ "$version" == "$start" ]; then
start_line=$line_num
fi
if [ "$version" == "$end" ]; then
end_line=$line_num
fi
done < "$AVAILABLE_FILE"
# Determine direction: if start_line > end_line, versions are newest-first
# Swap if needed so we always iterate from first_line to last_line
local first_line=$start_line
local last_line=$end_line
if [ $start_line -gt $end_line ] && [ $end_line -gt 0 ]; then
first_line=$end_line
last_line=$start_line
fi
# Second pass: collect versions in range
line_num=0
while IFS= read -r version; do
line_num=$((line_num + 1))
if [ $line_num -ge $first_line ] && [ $line_num -le $last_line ]; then
if [ -z "$found_versions" ]; then
found_versions="$version"
else
found_versions="$found_versions,$version"
fi
fi
done < "$AVAILABLE_FILE"
echo "$found_versions"
}
MISSING_VERSIONS=""
VALID_VERSIONS=""
if [ -n "$INPUT_VERSIONS" ]; then
# Check if it's a range format (contains - but not at start, and no comma)
if [[ "$INPUT_VERSIONS" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+-[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
# Range format: 7.0.5.0-7.0.9.0
START_VER=$(echo "$INPUT_VERSIONS" | cut -d'-' -f1)
END_VER=$(echo "$INPUT_VERSIONS" | cut -d'-' -f2)
echo "Processing version range: $START_VER to $END_VER"
# Check if start and end versions exist
if ! version_exists "$START_VER"; then
echo "::warning::Start version $START_VER not found in release history"
MISSING_VERSIONS="$START_VER"
fi
if ! version_exists "$END_VER"; then
echo "::warning::End version $END_VER not found in release history"
if [ -z "$MISSING_VERSIONS" ]; then
MISSING_VERSIONS="$END_VER"
else
MISSING_VERSIONS="$MISSING_VERSIONS,$END_VER"
fi
fi
# Expand range to get all versions
VALID_VERSIONS=$(expand_range "$START_VER" "$END_VER")
if [ -z "$VALID_VERSIONS" ]; then
echo "::warning::No versions found in range $START_VER to $END_VER"
fi
else
# Comma-separated or single version
IFS=',' read -ra VERSION_ARRAY <<< "$INPUT_VERSIONS"
for version in "${VERSION_ARRAY[@]}"; do
version=$(echo "$version" | xargs) # Trim whitespace
if version_exists "$version"; then
if [ -z "$VALID_VERSIONS" ]; then
VALID_VERSIONS="$version"
else
VALID_VERSIONS="$VALID_VERSIONS,$version"
fi
else
echo "::warning::Version $version not found in release history - will be skipped"
if [ -z "$MISSING_VERSIONS" ]; then
MISSING_VERSIONS="$version"
else
MISSING_VERSIONS="$MISSING_VERSIONS,$version"
fi
fi
done
fi
fi
echo "valid_versions=$VALID_VERSIONS" >> $GITHUB_OUTPUT
echo "missing_versions=$MISSING_VERSIONS" >> $GITHUB_OUTPUT
if [ -n "$MISSING_VERSIONS" ]; then
echo "::warning::The following versions were not found and will be skipped: $MISSING_VERSIONS"
fi
echo "Valid versions to process: $VALID_VERSIONS"
- name: Check base image for updates
id: check-base-image
env:
DEFAULT_IMAGE: ${{ env.DEFAULT_BASE_IMAGE }}
run: |
source "${{ github.workspace }}/.github/scripts/lib-retry.sh"
# Resolve base image from selection (same logic as build-platform job)
BASE_SELECTION="${{ steps.resolve-base-image.outputs.selection }}"
CUSTOM_BASE="${{ steps.resolve-base-image.outputs.custom }}"
case "$BASE_SELECTION" in
"custom")
BASE_IMAGE="${CUSTOM_BASE:-$DEFAULT_IMAGE}"
;;
*)
BASE_IMAGE="$DEFAULT_IMAGE"
;;
esac
LATEST_VERSION="${{ steps.fetch-releases.outputs.latest_version }}"
UPDATE_ENABLED="${{ github.event.inputs.update_base_image || env.UPDATE_BASE_IMAGE_ENABLED }}"
echo "Base image update check enabled: $UPDATE_ENABLED"
if [ "$UPDATE_ENABLED" != "true" ]; then
echo "updated=false" >> $GITHUB_OUTPUT
echo "Base image update check is disabled"
exit 0
fi
echo "Checking if base image has updates..."
# Get base image digest (crane digest with retry, falls back to skopeo)
BASE_DIGEST=$(run_with_retry_output crane digest "${BASE_IMAGE}" 2>/dev/null || run_with_retry_output skopeo inspect "docker://${BASE_IMAGE}" 2>/dev/null | jq -r '.Digest' || echo "unknown")
echo "Base image digest: $BASE_DIGEST"
if [ "$BASE_DIGEST" == "unknown" ] || [ -z "$BASE_DIGEST" ]; then
echo "::warning::Could not fetch base image digest"
echo "updated=false" >> $GITHUB_OUTPUT
exit 0
fi
# Check if our latest image exists and get its base layer info
# We store base image digest in labels during build
CURRENT_IMAGE="${{ env.DOCKERHUB_REPO }}:${LATEST_VERSION}"
CURRENT_BASE_LABEL=$(run_with_retry_output skopeo inspect "docker://${CURRENT_IMAGE}" 2>/dev/null | jq -r '.Labels["org.opencontainers.image.base.digest"] // empty' || echo "")
if [ -z "$CURRENT_BASE_LABEL" ]; then
# Fallback: check image creation time vs base image creation time
CURRENT_CREATED=$(run_with_retry_output skopeo inspect "docker://${CURRENT_IMAGE}" 2>/dev/null | jq -r '.Created' || echo "")
BASE_CREATED=$(run_with_retry_output skopeo inspect "docker://${BASE_IMAGE}" 2>/dev/null | jq -r '.Created' || echo "")
if [ -n "$CURRENT_CREATED" ] && [ -n "$BASE_CREATED" ]; then
CURRENT_EPOCH=$(date -d "$CURRENT_CREATED" +%s 2>/dev/null || echo "0")
BASE_EPOCH=$(date -d "$BASE_CREATED" +%s 2>/dev/null || echo "0")
if [ "$BASE_EPOCH" -gt "$CURRENT_EPOCH" ]; then
echo "updated=true" >> $GITHUB_OUTPUT
echo "Base image is newer than current build - rebuild recommended"
exit 0
fi
fi
echo "updated=false" >> $GITHUB_OUTPUT
echo "No base image update detected (no label, using timestamp comparison)"
else
if [ "$CURRENT_BASE_LABEL" != "$BASE_DIGEST" ]; then
echo "updated=true" >> $GITHUB_OUTPUT
echo "Base image digest changed: $CURRENT_BASE_LABEL -> $BASE_DIGEST"
else
echo "updated=false" >> $GITHUB_OUTPUT
echo "Base image unchanged"
fi
fi
- name: Check for 5-day stable promotion
id: check-stable
run: |
ACTION="${{ steps.set-action.outputs.action }}"
FORCE_PROMOTE_STABLE="false"
if [[ "$ACTION" == "force-promote-stable" ]]; then
FORCE_PROMOTE_STABLE="true"
fi
# Find latest non-beta release candidate (do not reset when latest release is beta)
CANDIDATE_VERSION=""
CANDIDATE_DATE=""
while IFS='|' read -r VERSION DATE IS_BETA; do
if [ "$IS_BETA" != "true" ] && [ -n "$VERSION" ]; then
CANDIDATE_VERSION="$VERSION"
CANDIDATE_DATE="$DATE"
break
fi
done < /tmp/releases.txt
# A candidate spliced in from the download API has no release date yet. Hold the
# promotion rather than aging an older build into :stable ahead of schedule --
# the real date arrives once the release-history page lists it.
if [ -n "$CANDIDATE_VERSION" ] && ! echo "$CANDIDATE_DATE" | grep -qE '^[0-9]{1,2}/[0-9]{1,2}/[0-9]{4}$'; then
echo "::warning::$CANDIDATE_VERSION has no release date yet (not on the release-history page) -- holding :stable promotion"
echo "should_mark_stable=false" >> $GITHUB_OUTPUT
echo "stable_version=" >> $GITHUB_OUTPUT
exit 0
fi
if [ -z "$CANDIDATE_VERSION" ] || [ -z "$CANDIDATE_DATE" ]; then
echo "::warning::Could not determine a non-beta stable candidate"
echo "should_mark_stable=false" >> $GITHUB_OUTPUT
echo "stable_version=" >> $GITHUB_OUTPUT
exit 0
fi
# Convert date (M/D/YYYY) to epoch
# Handle both 1/1/2025 and 12/27/2025 formats
MONTH=$(echo "$CANDIDATE_DATE" | cut -d'/' -f1)
DAY=$(echo "$CANDIDATE_DATE" | cut -d'/' -f2)
YEAR=$(echo "$CANDIDATE_DATE" | cut -d'/' -f3)
LATEST_EPOCH=$(date -d "$YEAR-$MONTH-$DAY" +%s 2>/dev/null || echo "0")
CURRENT_EPOCH=$(date +%s)
# Parse STABLE_PROMOTION_AGE (e.g. "5d", "7d", "30d", "12h"; empty = disabled)
PROMO_AGE="${{ env.STABLE_PROMOTION_AGE }}"
if [[ -z "$PROMO_AGE" ]]; then
echo "::warning::STABLE_PROMOTION_AGE is empty -- stable promotion is disabled. Set it to a value like '5d', '12h', or '30m' to enable."
echo "should_mark_stable=false" >> $GITHUB_OUTPUT
echo "stable_version=" >> $GITHUB_OUTPUT
exit 0
fi
PROMO_VALUE=$(echo "$PROMO_AGE" | grep -oP '^\d+')
PROMO_UNIT=$(echo "$PROMO_AGE" | grep -oP '[a-z]+$')
case "$PROMO_UNIT" in
h) PROMO_SECONDS=$(( PROMO_VALUE * 3600 )) ;;
d) PROMO_SECONDS=$(( PROMO_VALUE * 86400 )) ;;
*) PROMO_SECONDS=$(( PROMO_VALUE * 86400 )) ;; # default to days
esac
AGE_SECONDS=$(( CURRENT_EPOCH - LATEST_EPOCH ))
DAYS_OLD=$(( AGE_SECONDS / 86400 ))
HOURS_OLD=$(( AGE_SECONDS / 3600 ))
echo "Latest non-beta release ($CANDIDATE_VERSION) is $DAYS_OLD days old ($HOURS_OLD hours)"
echo "Stable promotion threshold: $PROMO_AGE ($PROMO_SECONDS seconds)"
if [ "$FORCE_PROMOTE_STABLE" == "true" ]; then
echo "should_mark_stable=true" >> $GITHUB_OUTPUT
echo "stable_version=$CANDIDATE_VERSION" >> $GITHUB_OUTPUT
echo "Force-promote enabled, selecting $CANDIDATE_VERSION for stable"
elif [ $LATEST_EPOCH -gt 0 ] && [ $AGE_SECONDS -ge $PROMO_SECONDS ]; then
echo "should_mark_stable=true" >> $GITHUB_OUTPUT
echo "stable_version=$CANDIDATE_VERSION" >> $GITHUB_OUTPUT
echo "Version $CANDIDATE_VERSION is $DAYS_OLD days old, eligible for stable (threshold: $PROMO_AGE)"
else
echo "should_mark_stable=false" >> $GITHUB_OUTPUT
echo "stable_version=" >> $GITHUB_OUTPUT
echo "Version $CANDIDATE_VERSION not old enough for stable yet (age: ${HOURS_OLD}h, threshold: $PROMO_AGE)"
fi
- name: Determine versions to build
id: determine-versions
run: |
ACTION="${{ steps.set-action.outputs.action }}"
FORCE_BUILD="${{ env.FORCE_BUILD }}"
SKIP_EXISTING="${{ github.event.inputs.skip_existing || github.event.client_payload.skip_existing }}"
LATEST_VERSION="${{ steps.fetch-releases.outputs.latest_version }}"
BETA_VERSIONS='${{ steps.fetch-releases.outputs.beta_versions }}'
# Check a single version against both registries using digest comparison (background job)
# A version is "exists" only if both registries have the tag AND digests match.
# If digests differ, it's flagged as missing so sync/rebuild can fix it.
# SHA-256 of empty input — used to detect failed skopeo fallback
EMPTY_SHA="sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
get_digest() {
local ref=$1 digest=""
digest=$(crane digest "$ref" 2>/dev/null) && [[ -n "$digest" ]] && echo "$digest" && return 0
local raw; raw=$(skopeo inspect --raw "docker://$ref" 2>/dev/null)
[[ -n "$raw" ]] || return 1
digest=$(echo "$raw" | sha256sum | awk '{print "sha256:"$1}')
[[ "$digest" != "$EMPTY_SHA" ]] && echo "$digest" && return 0
return 1
}
check_version_exists() {
local version=$1 check_tag=$2 result_dir=$3
local dh_digest="" ghcr_digest=""
dh_digest=$(get_digest "${{ env.DOCKERHUB_REPO }}:${check_tag}") || dh_digest=""
ghcr_digest=$(get_digest "${{ env.GHCR_REPO }}:${check_tag}") || ghcr_digest=""
if [[ -n "$dh_digest" && -n "$ghcr_digest" && "$dh_digest" == "$ghcr_digest" ]]; then
echo "exists" > "${result_dir}/${version}"
echo "Skipping version $version (tag: $check_tag) - digest match in both registries ($dh_digest)" >&2
else
echo "missing" > "${result_dir}/${version}"
if [[ -n "$dh_digest" && -n "$ghcr_digest" ]]; then
echo "Version $version (tag: $check_tag) - digest MISMATCH (DH: $dh_digest, GHCR: $ghcr_digest)" >&2
fi
fi
}
filter_existing_versions() {
local versions=$1
local filtered=""
if [ "$SKIP_EXISTING" != "true" ] || [ "$FORCE_BUILD" == "true" ]; then
echo "$versions"
return 0
fi
local result_dir
result_dir="$(mktemp -d)"
# Launch all version checks in parallel
IFS=',' read -ra VERSION_ARRAY <<< "$versions"
for version in "${VERSION_ARRAY[@]}"; do
version=$(echo "$version" | xargs)
IS_BETA=$(echo "$BETA_VERSIONS" | jq -r --arg v "$version" '.[$v] // false')
if [ "$IS_BETA" == "true" ]; then
CHECK_TAG="${version}-beta"
else
CHECK_TAG="${version}"
fi
check_version_exists "$version" "$CHECK_TAG" "$result_dir" &
done
wait
# Collect results in original order
for version in "${VERSION_ARRAY[@]}"; do
version=$(echo "$version" | xargs)
if [[ -f "${result_dir}/${version}" && "$(cat "${result_dir}/${version}")" == "missing" ]]; then
if [ -z "$filtered" ]; then
filtered="$version"
else
filtered="$filtered,$version"
fi
fi
done
rm -rf "$result_dir"
echo "$filtered"
}
# Check if base image update should trigger rebuild
BASE_IMAGE_UPDATED="${{ steps.check-base-image.outputs.updated }}"
UPDATE_BASE_IMAGE="${{ github.event.inputs.update_base_image || env.UPDATE_BASE_IMAGE_ENABLED }}"
case "$ACTION" in
"auto-check")
# Scan all available versions for missing builds, oldest first
ALL_VERSIONS=$(tac /tmp/available_versions.txt | paste -sd ',' -)
# Pre-filter excluded versions before registry scan to avoid wasted checks
EXCLUDE_LIST="${{ env.EXCLUDE_VERSIONS }}"
if [[ -n "$EXCLUDE_LIST" ]]; then
FILTERED_ALL=""
IFS=',' read -ra ALL_ARR <<< "$ALL_VERSIONS"
for v in "${ALL_ARR[@]}"; do
v=$(echo "$v" | xargs)
if echo ",$EXCLUDE_LIST," | grep -q ",$v,"; then
echo "Pre-excluding version $v (in EXCLUDE_VERSIONS)"
else
FILTERED_ALL="${FILTERED_ALL:+$FILTERED_ALL,}$v"
fi
done
ALL_VERSIONS="$FILTERED_ALL"
fi
echo "Scanning versions for missing builds: $ALL_VERSIONS"
# Use filter_existing_versions to find which ones are missing
# Temporarily force skip_existing behavior for auto-check
ORIG_SKIP="$SKIP_EXISTING"
SKIP_EXISTING="true"
MISSING_VERSIONS=$(filter_existing_versions "$ALL_VERSIONS")
SKIP_EXISTING="$ORIG_SKIP"