Repository navigation
1136 lines (1052 loc) · 49.6 KB
/
Copy pathci.yml
File metadata and controls
1136 lines (1052 loc) · 49.6 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
# Validation pipeline: everything needed to trust a commit, nothing that
# publishes. Runs directly for pull requests (fork-safe: no secrets, no
# registry writes) and is called as a reusable workflow by release.yml for
# pushes to main. See docs/ci-overview.md for the full CI/CD design.
#
# Trust model in one rule: untrusted (PR) code is built and tested, but never
# meets a write token. On PRs images move between jobs as artifacts; on
# trusted runs they are pushed to ghcr.io so later jobs (and the release
# workflow) can pull them. GitHub enforces the fork side of this: fork PRs
# always get a read-only GITHUB_TOKEN, whatever permissions are declared here.
name: CI
on:
# Every pull request, whatever its base — deliberately NOT filtered to `branches: [main]`.
# A stacked PR (PR N based on PR N-1's branch) never matches a main-only filter, so it
# merged with no lint, no unit tests, and no e2e: PR #255 ran only the external review
# bots. Retargeting does not rescue it either — when the base branch merges, GitHub
# retargets the open PR onto main and fires `pull_request` with action `edited`, which
# is not in the default trigger set (opened/synchronize/reopened), so nothing re-runs.
# Validating every base costs a little runner time and is what makes a stack reviewable
# one PR at a time.
pull_request:
workflow_call:
outputs:
ci-image:
description: CI base container image (pushed to ghcr.io on trusted runs)
value: ${{ jobs.ci-container.outputs.image }}
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: configbutler/gitops-reverser
IMAGE_TAG: ci-${{ github.sha }}
CI_WORKDIR: /workspaces/${{ github.event.repository.name }}
permissions:
contents: read
# PR runs dedupe per PR (a new push cancels the previous run). When called
# from release.yml there is no PR number; github.run_id keeps the group unique
# so the called run never interferes with the caller's own concurrency group.
concurrency:
group: ci-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
ci-container:
name: CI base container
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
image: ${{ steps.image.outputs.name }}
permissions:
contents: read
# Only trusted (non-PR) runs push. Fork PRs get a read-only token from
# GitHub regardless of this declaration; same-repo PR authors already
# have repo write access, so nothing is escalated for them either.
packages: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set image name
id: image
run: |
IMAGE="${REGISTRY}/configbutler/gitops-reverser-ci:${{ github.sha }}"
echo "Building CI base container: ${IMAGE}"
echo "name=${IMAGE}" >> "$GITHUB_OUTPUT"
- name: Build CI base container
# PRs build and load locally only (their token cannot publish, and
# untrusted code must not publish anyway); trusted runs also push.
# :latest is only moved from main so a workflow_dispatch on a topic
# branch can't repoint it.
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: .devcontainer/Dockerfile
target: ci
push: ${{ github.event_name != 'pull_request' }}
load: true # So the validate step below can run without pulling
tags: |
${{ steps.image.outputs.name }}
${{ github.ref == 'refs/heads/main' && format('{0}/configbutler/gitops-reverser-ci:latest', env.REGISTRY) || '' }}
cache-from: type=gha,scope=ci-container
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max,scope=ci-container' || '' }}
- name: Validate CI container tools
run: |
docker run --rm ${{ steps.image.outputs.name }} bash -lc "
set -e
echo '=== Validating CI Container Tools ==='
go version
kubectl version --client
kustomize version
helm version
flux-operator version --client
golangci-lint version
actionlint --version
hadolint --version
markdownlint-cli2 --version
vale --version
controller-gen --version
k3d version
docker --version
cosign version
echo '✅ All CI container tools verified'
"
- name: Save CI container image (PR)
# PR builds aren't pushed; hand the image to later jobs as an artifact.
if: github.event_name == 'pull_request'
run: docker save "${{ steps.image.outputs.name }}" -o ci-container-image.tar
- name: Upload CI container image artifact (PR)
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ci-container-image
path: ci-container-image.tar
if-no-files-found: error
retention-days: 1
build:
name: Project image (instrumented, artifact-only)
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
image: ${{ steps.image.outputs.name }}
permissions:
# No push on any run: this image is never published, so no write token.
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set full image name and build metadata
id: image
run: |
FULL_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}"
echo "name=${FULL_IMAGE}" >> "$GITHUB_OUTPUT"
echo "git_commit=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
echo "Building image: ${FULL_IMAGE}"
- name: Build Docker image
# Coverage-instrumented (GOCOVER=1), amd64-only. Never pushed on any
# run: delivered to later jobs (e2e) as a docker-save artifact and
# imported into k3d via IMAGE_DELIVERY_MODE=load — the fork-safe path
# every PR already exercises, now the only path. The registry-served
# bytes are the release-grade build-release-* images, so no
# instrumented digest can ever be mistaken for promotable. Keeps the
# ci-<sha> tag name only as a local Docker/k3d reference.
uses: ./.github/actions/docker-build
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
login: false
platforms: linux/amd64
tags: ${{ steps.image.outputs.name }}
push: false
load: true
# Own cache scope so the instrumented build and the release-grade
# build-linux/amd64 scope no longer invalidate each other.
cache-scope: build-instrumented-linux/amd64
cache-to: ${{ github.event_name != 'pull_request' && 'type=gha,mode=max,scope=build-instrumented-linux/amd64' || '' }}
build-args: |
VERSION=${{ env.IMAGE_TAG }}
GIT_COMMIT=${{ steps.image.outputs.git_commit }}
GIT_DIRTY=0
BUILD_DATE=${{ steps.image.outputs.build_date }}
GOCOVER=1
- name: Save project image
# Delivered as an artifact on every run (PR and main): e2e loads it and
# imports it into k3d.
run: docker save "${{ steps.image.outputs.name }}" -o project-image.tar
- name: Upload project image artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: project-image
path: project-image.tar
if-no-files-found: error
# ~7 days (was 1): a failed main e2e can still be reproduced from the
# exact instrumented image that ran, now that it is never pushed.
retention-days: 7
# Release-grade images: clean (no coverage), semver-stamped, pushed by digest
# on every push to main so the release tail can retag instead of rebuild.
# Two named jobs, not a matrix: publish-manifest needs each per-arch digest
# individually, and matrix jobs share one output set (last writer wins).
#
# Gated on `push` + `refs/heads/main` (not `!= 'pull_request'`, which would
# also fire release builds from a workflow_dispatch on any branch). Note:
# gating on `event_name == 'workflow_call'` would never match — inside a
# called workflow the github context belongs to the *caller*, so a release.yml
# push shows up here as event_name == 'push'.
build-release-amd64:
name: Release image linux/amd64
runs-on: ubuntu-latest
timeout-minutes: 30
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
digest: ${{ steps.build.outputs.digest }}
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Compute release build metadata
id: meta
run: |
# On the release-bump commit this is the new semver — release-please
# bumped .release-please-manifest.json in the PR before merge. On
# ordinary main pushes it is the *previous* released version; that is
# fine because only the bump commit's digest is ever promoted to a
# semver tag, and GIT_COMMIT disambiguates never-promoted candidates.
VERSION="$(jq -r '."."' .release-please-manifest.json)"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "git_commit=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Extract OCI labels
id: docker_meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push release image by digest
id: build
uses: ./.github/actions/docker-build
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
platforms: linux/amd64
labels: ${{ steps.docker_meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true
# Reuses the release cache scope: same build args at release time, so
# every main push keeps this cache warm.
cache-scope: build-linux/amd64
cache-to: type=gha,mode=max,scope=build-linux/amd64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
GIT_COMMIT=${{ steps.meta.outputs.git_commit }}
GIT_DIRTY=0
BUILD_DATE=${{ steps.meta.outputs.build_date }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: digests-amd64
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
build-release-arm64:
name: Release image linux/arm64
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
digest: ${{ steps.build.outputs.digest }}
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Compute release build metadata
id: meta
run: |
# See build-release-amd64: previous version on non-bump commits is
# expected and harmless.
VERSION="$(jq -r '."."' .release-please-manifest.json)"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "git_commit=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT"
echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Extract OCI labels
id: docker_meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push release image by digest
id: build
uses: ./.github/actions/docker-build
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
platforms: linux/arm64
labels: ${{ steps.docker_meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true
cache-scope: build-linux/arm64
cache-to: type=gha,mode=max,scope=build-linux/arm64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
GIT_COMMIT=${{ steps.meta.outputs.git_commit }}
GIT_DIRTY=0
BUILD_DATE=${{ steps.meta.outputs.build_date }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: digests-arm64
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
validate-devcontainer:
name: Validate Dev Container
runs-on: ubuntu-latest
timeout-minutes: 30
needs: ci-container
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build dev container (validation only)
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
file: .devcontainer/Dockerfile
target: dev
push: false
load: true
tags: gitops-reverser-devcontainer:test
cache-from: type=gha,scope=devcontainer
cache-to: ${{ github.event_name == 'pull_request' && '' || 'type=gha,scope=devcontainer,mode=max' }}
build-args: |
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
- name: Validate dev container tools
run: |
docker run --rm gitops-reverser-devcontainer:test bash -lc "
set -e
export PATH=/usr/local/go/bin:/go/bin:\$PATH
echo '=== Validating Dev Container Tools ==='
go version
kubectl version --client
kustomize version
helm version
flux-operator version --client
golangci-lint version
actionlint --version
markdownlint-cli2 --version
vale --version
k3d version
task --version
bash -ic 'complete -p task >/dev/null'
docker --version
echo '✅ All dev container tools verified'
"
lint-helm:
name: Build Helm chart and installer bundle
runs-on: ubuntu-latest
timeout-minutes: 20
needs: ci-container
permissions:
contents: read
packages: read
env:
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to Docker registry
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Download CI container image (PR)
if: github.event_name == 'pull_request'
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ci-container-image
path: .
- name: Load CI container image (PR)
if: github.event_name == 'pull_request'
# rm after load: the multi-GB tarball otherwise stays on disk for the whole
# job — e2e runners have been observed within 1.5GB of the kubelet eviction
# threshold (run 28623232380).
run: docker load -i ci-container-image.tar && rm -f ci-container-image.tar
- name: Lint and package Helm chart
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-w "${{ env.CI_WORKDIR }}" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task helm-sync
helm lint charts/gitops-reverser
helm template gitops-reverser charts/gitops-reverser \
--namespace gitops-reverser-system \
--set image.repository=test/image \
--set image.tag=test
task dist-install
helm package charts/gitops-reverser --destination .
mv gitops-reverser-*.tgz gitops-reverser.tgz
'
- name: Upload release bundle artifact
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-bundle
path: |
dist/crds.yaml
dist/commitrequest-crd.yaml
dist/install.yaml
gitops-reverser.tgz
if-no-files-found: error
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 20
needs: ci-container
permissions:
contents: read
packages: read
env:
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to Docker registry
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Download CI container image (PR)
if: github.event_name == 'pull_request'
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ci-container-image
path: .
- name: Load CI container image (PR)
if: github.event_name == 'pull_request'
# rm after load: the multi-GB tarball otherwise stays on disk for the whole
# job — e2e runners have been observed within 1.5GB of the kubelet eviction
# threshold (run 28623232380).
run: docker load -i ci-container-image.tar && rm -f ci-container-image.tar
- name: Run lint tasks
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-w "${{ env.CI_WORKDIR }}" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task lint
cache_log="$(mktemp)"
task --dry --verbose lint 2>&1 | tee "${cache_log}"
for task_name in \
generate \
manifests \
helm-sync \
lint-golang \
lint-dockerfiles \
lint-actions \
lint-helm
do
if ! grep -Fq "Task \"${task_name}\" is up to date" "${cache_log}"; then
echo "Expected ${task_name} to be cached after task lint" >&2
exit 1
fi
done
# The five docs tasks are exempt on purpose, and all for one reason: none
# of them carries `sources:` (see Taskfile-build.yml). Their real input is
# "every tracked file" — every .md for lint-markdown and lint-prose, plus
# .go, .yml and .sh for lint-doc-links and lint-metric-names — which Task
# cannot express: an unrooted `**` glob walks external-sources/ and has
# OOMed the host, and a hand-maintained file list silently drifts out of
# sync with what the checkers scan. A stale "up to date" means the check
# never runs, and lint-metric-names exists precisely because a check that
# does not run reads as a confident zero. All four tools together cost a few
# seconds over the whole tree, so they always run. lint-docs itself is the
# aggregate and has no cmds of its own.
#
# Adding a task to `task lint` without `sources:` and without adding it
# here fails this step. That is deliberate: it forces the choice to be made
# rather than silently skipped.
#
# awk, not `grep -qv`: the CI image ships ugrep as grep, whose -q -v returns
# 1 even when -c reports a selected line. That would make this assertion
# silently pass whatever it was handed.
exempt="lint-docs|lint-doc-links|lint-markdown|lint-prose|lint-metric-names"
uncached="$(awk -v ex="^task: \\\\[(${exempt})\\\\]" "/^task: \\[/ && \$0 !~ ex" "${cache_log}")"
if [ -n "${uncached}" ]; then
echo "Expected second task lint dry-run to be fully cached apart from the docs tasks, but these would run:" >&2
printf "%s\n" "${uncached}" >&2
exit 1
fi
'
- name: GitOps layout baseline is current
# test/fixtures/gitops-layouts/support-today.md is generated from a live
# manifest-analyzer scan of the corpus. Regenerating it here and failing on a
# diff is what makes a change to the acceptance boundary show up in review as
# "exactly which fixtures moved, and in which direction". Without this check the
# baseline silently goes stale, and the corpus stops being evidence of anything.
#
# A failure is not necessarily a bug: if the boundary moved on purpose, run
# `task gitops-layouts-baseline` and commit the regenerated file. Review the diff.
#
# Kept out of `task lint` deliberately: the lint step above asserts that a second
# dry-run is fully cached, and this task's fingerprint would have to be threaded
# through that assertion for no benefit.
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-w "${{ env.CI_WORKDIR }}" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task gitops-layouts-baseline --force
if ! git diff --quiet -- test/fixtures/gitops-layouts/support-today.md; then
# No backticks in the message below. It is a double-quoted string inside a
# bash -lc block, where backticks are command substitution: the echo would
# RUN the task a second time and splice its stdout into the annotation.
echo "::error file=test/fixtures/gitops-layouts/support-today.md::The GitOps layout baseline is stale: the analyzer now reports something different for the corpus. If the acceptance boundary moved on purpose, run task gitops-layouts-baseline and commit the result."
git --no-pager diff -- test/fixtures/gitops-layouts/support-today.md
exit 1
fi
echo "baseline is current"
'
test:
name: Unit tests
runs-on: ubuntu-latest
timeout-minutes: 30
needs: ci-container
permissions:
contents: read
packages: read
id-token: write
env:
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to Docker registry
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Download CI container image (PR)
if: github.event_name == 'pull_request'
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ci-container-image
path: .
- name: Load CI container image (PR)
if: github.event_name == 'pull_request'
# rm after load: the multi-GB tarball otherwise stays on disk for the whole
# job — e2e runners have been observed within 1.5GB of the kubelet eviction
# threshold (run 28623232380).
run: docker load -i ci-container-image.tar && rm -f ci-container-image.tar
- name: Run tests
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-w "${{ env.CI_WORKDIR }}" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task test
'
- name: Make test artifacts readable by the runner
if: always()
run: sudo chmod -R a+rX cover.out .coverage-baseline config/crd/bases config/rbac config/webhook 2>/dev/null || true
- name: Upload unit coverage to Codecov
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: cover.out
flags: unit
use_oidc: true
fail_ci_if_error: false
image-scan:
name: Scan project image
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [build, ci-container]
# PRs only: scans the instrumented image the PR built (delivered as an
# artifact). On main the shipped bytes are the release-grade digests, which
# image-scan-release scans instead — so this scan reports to the job log
# and never needs a write token.
if: github.event_name == 'pull_request'
permissions:
contents: read
packages: read
env:
PROJECT_IMAGE: ${{ needs.build.outputs.image }}
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
# Inside the container this is a workspace path, so the runner-side cache step
# below can persist it between runs. Without a cache Trivy re-downloads its whole
# vulnerability DB on every job, which is slow and rate-limited at the source.
TRIVY_CACHE_DIR: ${{ github.workspace }}/.stamps/trivy-cache
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download project image
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: project-image
path: .
- name: Download CI container image
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ci-container-image
path: .
- name: Load CI container image
# rm after load: the tarball is multi-GB and otherwise occupies the runner
# for the rest of the job.
run: docker load -i ci-container-image.tar && rm -f ci-container-image.tar
- name: Restore Trivy vulnerability DB
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .stamps/trivy-cache
key: trivy-db-${{ github.run_id }}
restore-keys: trivy-db-
- name: Scan project image
# The project image is scanned straight from its artifact tarball, so this
# needs neither a `docker load` of it nor a registry pull. What the task does
# — report everything, then gate on CRITICAL-with-a-fix — is documented where
# it is defined; keeping it there is what lets a maintainer reproduce this
# exact gate with `task scan-image SCAN_ARCHIVE=project-image.tar`.
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-w "${{ env.CI_WORKDIR }}" \
-e TRIVY_CACHE_DIR="${{ env.CI_WORKDIR }}/.stamps/trivy-cache" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task scan-image SCAN_ARCHIVE=project-image.tar
'
- name: Make the Trivy cache readable by the runner
# The container writes it as root; actions/cache runs as the runner user.
if: always()
run: sudo chown -R "$(id -u):$(id -g)" .stamps/trivy-cache || true
image-scan-release:
name: Scan release image (${{ matrix.arch }})
runs-on: ubuntu-latest
timeout-minutes: 20
# Rides its build jobs' gate: when build-release-* are skipped (PRs,
# dispatch) this skips transitively — no skip-tolerant `if:` needed. The
# release ships amd64 *and* arm64, so both digests are scanned; Trivy pulls
# each by digest and analyzes the filesystem cross-arch.
needs: [ci-container, build-release-amd64, build-release-arm64]
permissions:
contents: read
packages: read
security-events: write
env:
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
TRIVY_CACHE_DIR: ${{ github.workspace }}/.stamps/trivy-cache
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
digest: ${{ needs.build-release-amd64.outputs.digest }}
- arch: arm64
digest: ${{ needs.build-release-arm64.outputs.digest }}
steps:
- name: Checkout code
# This job scans a REMOTE image by digest and needs nothing from the repository
# except the Taskfile and .trivyignore.yaml the scan below reads. Trivy treats a
# missing --ignorefile as a fatal error, so a lost checkout fails loudly here
# rather than quietly scanning without the suppressions.
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to registry
# Each build-release-* job pushes by digest, and buildx wraps the
# single-platform image in an OCI *index* (it attaches a provenance
# attestation manifest). Trivy resolves the digest remotely but, for an
# index ref, selects the child matching the *runner's* platform — so on
# this amd64 runner the arm64 leg fails with "no child with platform
# linux/amd64 in index". The Trivy steps below pin TRIVY_PLATFORM to the
# matrix arch to select the right child. The login just covers
# authenticated pulls; the package is public anyway.
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Restore Trivy vulnerability DB
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .stamps/trivy-cache
key: trivy-db-${{ github.run_id }}
restore-keys: trivy-db-
- name: Scan release image
# The same `task scan-image` the PR gate runs, so the release and the PR are
# held to one definition of what blocks. It writes SARIF for code scanning and
# then gates; the upload below runs even when the gate fails, so a blocking
# finding is still reported rather than only logged.
#
# Trivy pulls the image with its OWN registry client rather than through the
# docker daemon, so the runner's `docker login` above does not reach it: the
# credentials live in the runner's home, and this container sees only the
# workspace. Mounting that config read-only and pointing DOCKER_CONFIG at it is
# what keeps this working if the package ever stops being public. Read-only, and
# outside the workspace, so the credential is never somewhere an artifact upload
# could pick it up. DOCKER_CONFIG rather than a mount onto ~/.docker because it
# does not care which user the image runs as.
run: |
docker run --rm \
-v "${GITHUB_WORKSPACE}:${{ env.CI_WORKDIR }}" \
-v "${HOME}/.docker:/tmp/docker-config:ro" \
-w "${{ env.CI_WORKDIR }}" \
-e DOCKER_CONFIG=/tmp/docker-config \
-e TRIVY_CACHE_DIR="${{ env.CI_WORKDIR }}/.stamps/trivy-cache" \
-e TRIVY_PLATFORM="linux/${{ matrix.arch }}" \
"${CI_CONTAINER}" \
bash -lc '
set -e
git config --global --add safe.directory "$PWD"
task scan-image \
SCAN_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ matrix.digest }} \
SCAN_SARIF=trivy-image.sarif
'
- name: Make scan output readable by the runner
# The container writes both as root; the upload action and actions/cache run
# as the runner user.
if: always()
run: sudo chown -R "$(id -u):$(id -g)" .stamps/trivy-cache trivy-image.sarif || true
- name: Upload scan results to GitHub code scanning
# always(): the SARIF is written by the report pass BEFORE the gate runs, so a
# gate failure must not swallow the report that explains it.
if: always()
uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
sarif_file: trivy-image.sarif
# Distinct category so the amd64 and arm64 legs don't overwrite each
# other's code-scanning results.
category: trivy-release-${{ matrix.arch }}
e2e:
name: E2E (${{ matrix.name }})
runs-on: ubuntu-latest
timeout-minutes: 45
needs: [ci-container, build, lint-helm]
permissions:
contents: read
packages: read
id-token: write
strategy:
# Don't let one lane's flake cancel the other mid-run — a cancelled lane
# loses its signal *and* fails its report upload with "No files found".
fail-fast: false
matrix:
include:
# Functional suite, sharded across two runners (was the single `full`
# lane). The two label filters are exact complements within the old
# `!image-refresh` filter — `manager` ∪ `!manager && !image-refresh`
# equals what `full` ran — so no spec is added or dropped, the work is
# just split across two clusters. The membership is a provisional
# first cut; rebalance from the e2e-ginkgo-reports-* per-spec timings
# (docs/finished/e2e-ci-runner-sharding-plan.md, "balance from real data").
# Each shard runs config-dir installs (GOCOVERDIR overlay) and collects
# coverage; Codecov unions same-flag `e2e` uploads, so merged coverage
# is the union of both shards — the non-regression ratchet still holds.
- name: full-manager
script: "task test-e2e"
e2e_label_filter: "manager"
e2e_report_name: "full-manager"
needs_artifact: false
coverage: "1"
# Bumped 1 -> 4: the full suite was serialized to avoid the
# cross-target rule-change snapshot coupling, now fixed (per-target
# effective watch plan — docs/spec/gittarget-isolation-on-rule-change.md).
e2e_ginkgo_procs: "4"
- name: full-core
script: "task test-e2e"
# `bi-directional` and `source-cluster` moved out to their own legs (they
# install Argo CD / kcp that `task prepare-e2e` does not perform), so this
# filter is now a strict subset of what it used to run.
e2e_label_filter: "!manager && !image-refresh && !bi-directional && !source-cluster"
e2e_report_name: "full-core"
needs_artifact: false
coverage: "1"
e2e_ginkgo_procs: "4"
# The bi-directional corner: the only leg that installs Argo CD, and the
# only place gitops-reverser shares a path with a foreign GitOps engine.
# Its own runner (hence its own cluster) is what keeps the Argo CD
# install off the other three legs.
# docs/spec/e2e-bi-directional-corner.md.
#
# `task test-e2e-bi-directional` pins its own label filter and Ginkgo
# procs (BI_DIRECTIONAL_GINKGO_PROCS), so e2e_label_filter and
# e2e_ginkgo_procs are deliberately absent here rather than set to values
# the task would ignore. It uses the default config-dir install, so it
# carries the GOCOVERDIR overlay and its coverage unions with the others'.
- name: bi-directional
script: "task test-e2e-bi-directional"
e2e_report_name: "bi-directional"
needs_artifact: false
coverage: "1"
# The source-cluster corner: the only leg that installs kcp, and the only
# place gitops-reverser mirrors REMOTE clusters (GitTarget.spec.kubeConfig).
# kcp workspaces are cheap logical clusters, so its own runner installs a
# small kcp control plane and provisions three workspaces as source clusters.
# docs/finished/config-plane-split.md.
#
# `task test-e2e-source-cluster` pins its own label filter and Ginkgo procs
# (SOURCE_CLUSTER_GINKGO_PROCS=1 — the specs share one kcp port-forward), so
# e2e_label_filter and e2e_ginkgo_procs are deliberately absent here. It uses
# the default config-dir install, so it carries the GOCOVERDIR overlay and its
# coverage unions with the others'.
- name: source-cluster
script: "task test-e2e-source-cluster"
e2e_report_name: "source-cluster"
needs_artifact: false
coverage: "1"
# Quickstart chain, sharded across two runners (was the single
# `quickstart` lane). quickstart-install runs the two install-mode
# validations on one cluster (cleanup-installs.sh resets the namespace
# between them, since install stamps don't invalidate on an image-ref
# change). image-refresh gets its own runner: it validates the local
# build → k3d load → rollout restart chain, so it must run against a
# locally built image — unset PROJECT_IMAGE and force load delivery.
# Fork-safe: neither leg pushes to a registry. Helm/manifest installs
# don't carry the GOCOVERDIR overlay, so these legs collect no coverage.
- name: quickstart-install
script: >-
export INSTALL_MODE=helm HELM_CHART_SOURCE=./gitops-reverser.tgz
&& task test-e2e-quickstart-helm
&& bash hack/cleanup-installs.sh k3d-gitops-reverser-test-e2e
&& export INSTALL_MODE=plain-manifests-file
&& task test-e2e-quickstart-manifest
needs_artifact: true
coverage: ""
e2e_ginkgo_procs: "2"
- name: image-refresh
script: >-
export INSTALL_MODE=helm HELM_CHART_SOURCE=./gitops-reverser.tgz
&& env -u PROJECT_IMAGE IMAGE_DELIVERY_MODE=load task test-image-refresh
needs_artifact: true
coverage: ""
e2e_ginkgo_procs: "2"
env:
PROJECT_IMAGE: ${{ needs.build.outputs.image }}
CI_CONTAINER: ${{ needs.ci-container.outputs.image }}
# The instrumented project image is never pushed (PR *or* main), so it is
# always delivered via `load`: the artifact is docker-loaded below and
# imported into k3d. The CI container is still pulled from GHCR on main
# (publish-helm needs a registry ref), so that delivery stays branched.
IMAGE_DELIVERY_MODE: load
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Free runner disk
# The e2e stack (CI container, project image, k3d node volumes, local
# rebuilds) needs tens of GB; runners have been observed dipping below
# the k3d node's kubelet eviction threshold mid-run (28624646437).
# Drop the biggest preinstalled toolchains we never use.
run: |
df -h / | tail -1
sudo rm -rf /usr/local/lib/android /opt/hostedtoolcache/CodeQL /usr/share/dotnet &
RM_PID=$!
docker system prune -f >/dev/null 2>&1 || true
wait "${RM_PID}"
df -h / | tail -1
- name: Download release bundle artifact
if: matrix.needs_artifact
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-bundle
path: .
- name: Download CI container image (PR)
if: github.event_name == 'pull_request'
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ci-container-image
path: .
- name: Load CI container image (PR)
if: github.event_name == 'pull_request'
# rm after load: the multi-GB tarball otherwise stays on disk for the whole
# job — e2e runners have been observed within 1.5GB of the kubelet eviction
# threshold (run 28623232380).
run: docker load -i ci-container-image.tar && rm -f ci-container-image.tar
- name: Login to Docker registry
if: github.event_name != 'pull_request'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Download project image
# The project image is never pushed; pull the tarball built by the
# build job (on PR and main alike).
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: project-image
path: .
- name: Load project image into Docker
# Make PROJECT_IMAGE available in the runner's Docker daemon so the k3d
# image import (load delivery) finds it locally instead of pulling.
run: docker load -i project-image.tar && rm -f project-image.tar
- name: Start disk usage sampler
run: |
# Sample free space on / every 10s for the whole e2e run so we can see
# the peak (not just the post-run value). /var/lib/docker — k3d node
# volumes, pulled images, build cache — lives on this filesystem.
( while true; do
printf '%s avail=%s\n' "$(date -u +%H:%M:%S)" \
"$(df --output=avail -BM / | tail -1 | tr -d ' ')"
sleep 10