Repository navigation
865 lines (835 loc) · 46.4 KB
/
Copy pathrelease.yml
File metadata and controls
865 lines (835 loc) · 46.4 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
name: release
# Publishes the three container images the Helm chart and the compose files
# name: `ghcr.io/<owner>/vpay-{server,dashboard,checkout}` (step-6
# decision (1), plus `vpay-checkout` from Step 9's D3), where `<owner>` is
# `github.repository_owner` lowercased by the `namespace` job below rather
# than a literal. It used to be the literal `vaam-store`, and that is what
# broke: the organisation was renamed `vaam-store` -> `vaam-apps` on
# 2026-09-04, GHCR does not follow an organisation rename (a package lives
# under the installation that pushed it, not under the org's current name),
# and the first `master` run after the rename — `33894388991` — failed all
# eight `build` jobs at the push step with `denied: permission_denied: The
# requested installation does not exist`. Deriving the namespace from
# `github.repository_owner` means the next rename fixes this file for free.
# Nothing else in this repository pushes an image; `ci.yml` builds them and
# throws them away.
#
# This header used to open "NOTHING IN THIS FILE HAS EVER RUN … no image has
# been published and nothing has been signed", written 2026-09-03 when it was
# true. Corrected 2026-09-05: `gh run list --workflow release --branch master`
# returns 13 runs, 12 of them green (2026-09-03 15:25 UTC → 2026-09-04 23:24
# UTC); the single failure is `33894388991`, the rename breakage described
# above. In the latest, `33929374661` (head `33d6c25`), all 13 jobs succeeded
# and its log shows each of the four manifest lists pushed to `:edge` and
# `:sha-<40 hex>` —
# vpay-server sha256:5485db5e397edd8e672737e676756ca4e9eb56a23fb117a6bc762e0532b50537
# vpay-worker sha256:08667b03bae210802d04d59dba92820be9bccb4052f8337c74f0ea0a80d68a78
# vpay-dashboard sha256:ba6d6712dc143598c66c34300dffa3e38cdd5a21de98dfc9b43a13103b21a7a7
# vpay-checkout sha256:5214e408be6062123b51374d99988ef20e28081fa96e7bcb0eb4ac2b5b12e51e
# — and four `cosign sign` steps that each logged "Pushing signature to:" and
# a Rekor tlog index (server 2717616118, worker 2717617767, dashboard
# 2717616040, checkout 2717615975). What is still true of the original
# sentence, corrected 2026-09-19: it said **no `v*` tag has been pushed**, so
# the `type=semver` path below "has never been taken". That stopped being
# true on 2026-09-17. Three tags exist — `v0.1.1`, `v0.2.0` and `v0.2.1` —
# and this workflow ran on each (`35275194212`, `35361829971`,
# `35430925331`); every `build` and every `merge` job succeeded in all three,
# so the `type=semver` path HAS been taken and has produced real tags. The
# two later runs are red only on the npm publish jobs. What survives from
# that sentence is its last clause: nobody has run `cosign verify` against
# any of those digests. See docs/status.md and docs/runbooks/release.md.
#
# Two Dockerfiles, three targets, one shape: both are built from the
# repository root (`context: .`), exactly as `compose.e2e.yml` builds them.
# `backends/Dockerfile` needs the root context because it COPYs `sdks/rust`
# and `examples/merchant-demo` — cargo refuses to load a workspace whose
# `members` list names a missing directory, and both are members.
#
# One `--build-arg`, and only on `backends/Dockerfile`: `VPAY_GIT_SHA`, which
# becomes `vpay_build_info{git_sha="..."}` on the running binaries' /metrics.
# `frontends/Dockerfile` declares no ARG and is passed one anyway — buildx
# warns about an unused build argument rather than failing, and passing it to
# every image keeps the matrix from needing a per-image condition. If that
# warning is ever promoted to an error, split it per matrix entry.
on:
# `v*` tags are the real releases. Pushes to the default branch publish
# `:edge` so the chart has something to pull between releases — and so the
# first evidence that this workflow works arrives on merge rather than on a
# tag nobody wants to cut blind.
push:
tags: ["v*"]
branches: [master]
# Two publishes of the same ref must not race for the same tag. Not
# `cancel-in-progress`: a half-cancelled `imagetools create` leaves a tag
# pointing at whichever index won.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
REGISTRY: ghcr.io
# The floor every job gets: read the checkout, push to GHCR. Nothing more.
#
# `id-token: write` is NOT here. It is what makes cosign keyless signing
# possible at all — the workflow's OIDC token is the identity Fulcio
# certifies (step-6 decision (3)) — and it is also a credential that can be
# exchanged for cloud roles, so it belongs to the one job that signs
# (`merge`) rather than to the six `build` jobs, which never call cosign.
# A job-level `permissions:` block REPLACES this one, so `merge` restates
# `contents` and `packages` alongside it.
#
# `attestations: write` was declared per the step-6 design and is dropped:
# nothing here calls the attestations API. buildx writes its provenance and
# SBOM into the image index itself (`provenance: mode=max`, `sbom: true`),
# which needs only `packages: write`. Add it back in the job that needs it if
# `actions/attest-build-provenance` ever lands here.
#
# `packages: write` is NOT here either, for the same reason: only `build`
# and `merge` touch GHCR. `merge` already declared its own block; `build`
# used to inherit this one, and now declares it too. A workflow-level
# grant reaches every job, including the two npm publish jobs that must
# never be able to write a package to GHCR.
permissions:
contents: read
jobs:
# A GHCR path must be lowercase; `github.repository_owner` is not
# guaranteed to be (an organisation's display name can carry mixed case
# even though `vaam-store` and `vaam-apps` both happen not to). This is
# the only step in the workflow that touches the owner name, so `build`
# and `merge` below consume its output rather than each re-deriving it.
namespace:
name: derive registry namespace
runs-on: ubuntu-latest
outputs:
namespace: ${{ steps.namespace.outputs.namespace }}
steps:
- id: namespace
env:
OWNER: ${{ github.repository_owner }}
run: echo "namespace=${OWNER,,}" >> "$GITHUB_OUTPUT"
# One job per (image, architecture). Each pushes an *unnamed* manifest —
# `push-by-digest=true` — and hands its digest to `merge`, which is what
# assembles the multi-arch manifest list under the real tags.
#
# Native runners, not QEMU (step-6 decision (8)). `backends/Dockerfile`
# builds the builder's OWN host triple, read from `rustc -vV`, precisely so
# it is never a cross-compile; an arm64 runner keeps that invariant while an
# emulated one pays for `ring`'s asm and mimalloc's C build under QEMU.
build:
name: build ${{ matrix.image.name }} (${{ matrix.platform.arch }})
runs-on: ${{ matrix.platform.runner }}
needs: [namespace]
# Declared here rather than inherited from a workflow-level grant:
# this job pushes per-architecture images to GHCR, and it is the only
# job besides `merge` that needs to. `id-token: write` is deliberately
# absent - `merge` signs, these builds do not.
permissions:
contents: read
packages: write
env:
NAMESPACE: ${{ needs.namespace.outputs.namespace }}
strategy:
# One architecture failing should still show whether the other builds.
fail-fast: false
matrix:
image:
# ONE backend image, and it runs both backend workloads: with no
# argument it serves the API, with `worker` it runs the job loop.
# There was a second entry here, `vpay-worker` from
# `backends/Dockerfile`'s `worker` target, until 2026-09-07 (issue
# #77) — two `build` jobs, a `merge` job and a `cosign sign` for a
# second image built from the same `cargo` invocation and the same
# dependency graph as this one. `ghcr.io/<owner>/vpay-worker` is
# frozen at the last `:edge` it was pushed; nothing publishes to it
# any more and the chart no longer names it. See
# docs/flows/deployment.md.
- name: vpay-server
file: backends/Dockerfile
target: server
# `frontends/Dockerfile` builds both of these. Every target is named
# explicitly rather than left to "the last stage wins" — that
# property stopped holding the moment the file grew a second image,
# and the `runner` name is kept exactly so this entry did not have
# to change when it did.
- name: vpay-dashboard
file: frontends/Dockerfile
target: runner
# The hosted/embedded payment page (Step 9, D3). A second Next.js
# image rather than HTML from `vpay-server`, so it is a second
# deployable with its own Deployment, Service and Ingress in the
# chart (`checkout.enabled`, default false).
- name: vpay-checkout
file: frontends/Dockerfile
target: checkout
platform:
- arch: amd64
runner: ubuntu-latest
docker: linux/amd64
- arch: arm64
runner: ubuntu-24.04-arm
docker: linux/arm64
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# This job builds and pushes to GHCR with GITHUB_TOKEN, never
# over git. Leaving the checkout token in .git/config would let
# any later step - or anything that archives the workspace -
# reuse it.
persist-credentials: false
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Labels only here; the TAGS are applied once, by `merge`, to the
# manifest list. A per-architecture build that carried them would leave
# `:edge` pointing at whichever architecture pushed last.
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ matrix.image.name }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.licenses=Apache-2.0
org.opencontainers.image.vendor=${{ env.NAMESPACE }}
- id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ${{ matrix.image.file }}
target: ${{ matrix.image.target }}
platforms: ${{ matrix.platform.docker }}
# The one thing a build cannot work out for itself. `github.sha` is
# the merge/tag commit this workflow ran on, which is exactly what
# an operator holding a `vpay_build_info` label wants to `git show`.
build-args: |
VPAY_GIT_SHA=${{ github.sha }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ matrix.image.name }},push-by-digest=true,name-canonical=true,push=true
# `mode=max` records every build step, not just the final image —
# for a `FROM scratch` image with no shell to inspect it with, the
# provenance and the SBOM are the only description of what is
# inside. Both ride in the image index buildx pushes.
provenance: mode=max
sbom: true
# A `master` push rebuilds the whole musl workspace once per
# architecture. It was twice — `vpay-server` and `vpay-worker` were
# separate jobs sharing one builder stage but not one runner — until
# issue #77 made them one image; that is two `build` jobs and one
# `merge` job fewer per push, for the same bytes. The scope is per
# image AND per architecture because an amd64 layer is useless to an
# arm64 build.
# Unmeasured: whether eight `mode=max` scopes fit inside GitHub's
# per-repository cache budget without evicting each other. If they
# do not, the symptom is slow builds, not wrong ones — drop to
# `mode=min`, or drop the cache entirely.
#
# **These scopes mean exactly what they meant before 2026-09-05,
# when `backends/Dockerfile` gained cargo-chef** (see its header).
# A scope is still one image on one architecture, still `mode=max`,
# and nothing here changed. What changed is how much of a scope is
# reusable *across commits*: until that date `ARG`/`ENV
# VPAY_GIT_SHA` was the first instruction of the builder stage, and
# the `build-args` line below passes a different `github.sha` on
# every push — so every layer after it missed on every run, and
# `cache-from` could only ever restore the base image and the `apk
# add`. The `ARG` now sits after the dependency-compilation layer,
# so that layer survives a commit. **Measured by mutation on the
# authoring host, not on a runner:** with the `ARG` restored to the
# top, a build whose only change is the sha recompiles the
# dependency graph and takes 251 s; with it below, the same build
# reuses the layer and takes 116 s. Nobody has read a GitHub Actions
# cache-hit rate for this file, before or after.
cache-from: type=gha,scope=${{ matrix.image.name }}-${{ matrix.platform.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.image.name }}-${{ matrix.platform.arch }}
# An empty file named after the digest: the artifact IS the digest, so
# `merge` needs no output plumbing across a matrix it cannot index into.
- name: record the digest
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: digests-${{ matrix.image.name }}-${{ matrix.platform.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Assembles one multi-arch manifest list per image, applies the tags, and
# signs the resulting index digest.
merge:
name: manifest list + sign (${{ matrix.image.name }})
runs-on: ubuntu-latest
needs: [namespace, build]
env:
NAMESPACE: ${{ needs.namespace.outputs.namespace }}
# The only job that signs, and therefore the only one holding an OIDC
# token. A job-level block replaces the workflow-level one outright, so
# the two the workflow already grants are restated rather than inherited.
permissions:
contents: read
packages: write
id-token: write
strategy:
fail-fast: false
matrix:
image:
- name: vpay-server
- name: vpay-dashboard
- name: vpay-checkout
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: /tmp/digests
pattern: digests-${{ matrix.image.name }}-*
merge-multiple: true
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# The tags, computed once. `merge` is the only job that names them.
# v1.2.3 -> `1.2.3`, `1.2`, `sha-<40 hex>`
# push to master -> `edge`, `sha-<40 hex>`
# There is deliberately no `latest`: the chart pins a digest for
# anything real (deploy/helm/vpay/README.md), and a floating `latest`
# invites the one deployment shape this repository argues against.
- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ matrix.image.name }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=edge,enable={{is_default_branch}}
type=sha,format=long
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.licenses=Apache-2.0
org.opencontainers.image.vendor=${{ env.NAMESPACE }}
- uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
# `$DOCKER_METADATA_OUTPUT_JSON` is set by metadata-action above; each
# file in this directory is named after one per-architecture digest.
- name: create the manifest list
working-directory: /tmp/digests
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ matrix.image.name }}
run: |
set -euo pipefail
# Both substitutions are meant to word-split into separate argv
# entries; that is the documented buildx recipe for this step.
# shellcheck disable=SC2046
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${IMAGE}@sha256:%s " *)
# The digest of the INDEX, which is what a `cosign verify` of a tag
# resolves to and what `images.<component>.digest` pins in the chart.
- id: index
name: read back the manifest-list digest
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ matrix.image.name }}
VERSION: ${{ steps.meta.outputs.version }}
run: |
set -euo pipefail
digest="$(docker buildx imagetools inspect "${IMAGE}:${VERSION}" \
--format '{{json .Manifest}}' | jq -er .digest)"
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
echo "${IMAGE}@${digest}" >> "$GITHUB_STEP_SUMMARY"
# Keyless (step-6 decision (3)): no key exists to store or rotate. The
# Fulcio certificate binds this image to this workflow file at this ref,
# and the Rekor entry makes that public and append-only. The cost is
# named in ADR terms in docs/runbooks/release.md: renaming this file
# breaks every downstream `cosign verify`.
#
# The INDEX digest is signed, not the per-architecture children. A
# consumer that pulls by tag or by the index digest is covered; one that
# pins a child manifest's own digest is not.
- name: cosign sign (keyless, GitHub OIDC)
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ matrix.image.name }}
DIGEST: ${{ steps.index.outputs.digest }}
run: cosign sign --yes "${IMAGE}@${DIGEST}"
# --- Helm chart publishing --------------------------------------------
#
# The chart at `deploy/helm/vpay` goes to the same registry as the images
# it deploys, as an OCI artifact: `ghcr.io/<owner>/charts/vpay`. Helm has
# had no separate repository format requirement since 3.8 — an OCI registry
# IS the repository — so this adds a publish path and no new hosting.
#
# Four decisions, each of which the reader is entitled to disagree with,
# so each is argued rather than asserted:
#
# (1) `charts/` in the path, not `ghcr.io/<owner>/vpay`. The chart and the
# three images are packages under one owner, listed in one place, and
# `vpay` sitting beside `vpay-server` with a different artifact type
# inside it is a trap for whoever reads that list next. `charts/vpay`
# says what it is. It costs nothing: GHCR paths nest, and that is
# checked rather than assumed — `helm show chart
# oci://ghcr.io/prometheus-community/charts/prometheus --version 27.4.0`
# answers from a real, anonymous GHCR read (2026-09-19).
#
# (2) **Tags only.** There is no `edge` chart, deliberately, and this is
# the one place this workflow does not mirror what it does for images.
# An OCI chart's tag is not a label someone chose — `helm push` derives
# it from `Chart.yaml`'s `version:` and there is no second name to move
# — so an `edge` chart means either republishing one version over
# itself on every merge, or inventing `0.2.1-edge.<sha>` versions that
# accumulate forever and that `helm search` will happily offer. Between
# releases the chart is a directory in a clone (`helm upgrade --install
# vpay deploy/helm/vpay`), which is what `deploy/helm/vpay/README.md`
# has always told people to do and what `just helm-check` exercises on
# every PR.
#
# (3) `needs: merge`. The chart's `values.yaml` defaults `images.*.tag` to
# `.Chart.AppVersion`, so a chart published before the images it names
# are pushed is a chart that resolves to tags GHCR does not have yet.
# Depending on `merge` (all three images, all architectures, merged and
# signed) means the chart cannot exist before the thing it pulls.
#
# (4) Signed with cosign, keyless, exactly as the images are. A chart is an
# OCI manifest like any other and `cosign sign` treats it as one; the
# certificate identity is this same workflow file, so
# `docs/runbooks/release.md` §3's verification command works on the
# chart with only the reference changed.
#
# `Chart.yaml`'s `version:` is bumped by release-please as of 2026-09-20
# (it carries `x-release-please-version`, like `appVersion`), so chart
# version, appVersion and tag are one number. Until then it was hand-edited
# and `v0.2.2` shipped a chart numbered 0.2.1 because nobody remembered.
# The "already published" guard below stays regardless: it is what stops a
# `helm push` silently overwriting a chart people already have.
publish-chart:
name: publish the Helm chart to GHCR (OCI)
runs-on: ubuntu-latest
needs: [namespace, merge]
# A chart is only ever published for a release. See decision (2) above.
if: startsWith(github.ref, 'refs/tags/v')
env:
NAMESPACE: ${{ needs.namespace.outputs.namespace }}
# Same shape as `merge`, and for the same reasons: a job-level block
# replaces the workflow-level one outright, and `id-token: write` is
# here because this job signs.
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Nothing here pushes a commit; the chart goes to a registry, not
# to git.
persist-credentials: false
- uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
- uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
# Both versions in `Chart.yaml`, read once, with the empty-match scar
# `sdk-version` above carries verbatim: `[^"#]*`, never `.*$`, because
# both lines end in a comment and a pattern anchored to end-of-line
# matches nothing while the job stays green.
#
# BOTH must equal the tag. release-please owns both lines
# (`release-please-config.json`'s `extra-files`), so they do by
# construction on a tag it cut, and these checks are what catch a tag
# cut by hand — or an annotation that silently stopped being rewritten,
# which is exactly how this repository lost its Chart.yaml once.
#
# `version:` was deliberately NOT compared here until 2026-09-20, on
# the grounds that the chart had its own lifecycle. It no longer does:
# release-please bumps it, so a mismatch is now a defect rather than a
# legitimate difference.
- id: chart
name: read Chart.yaml, and refuse a tag its appVersion contradicts
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/v}"
VERSION="$(sed -n 's/^version: *\([^ #]*\).*$/\1/p' deploy/helm/vpay/Chart.yaml | head -1)"
APP_VERSION="$(sed -n 's/^appVersion: *"\([^"]*\)".*$/\1/p' deploy/helm/vpay/Chart.yaml | head -1)"
echo "tag=${TAG} chart-version=${VERSION} appVersion=${APP_VERSION}"
[[ -n "$VERSION" ]] || { echo "::error::Chart.yaml has no readable 'version:' (blank means the sed pattern matched nothing)"; exit 1; }
[[ "$APP_VERSION" == "$TAG" ]] || { echo "::error::Chart.yaml appVersion '${APP_VERSION}' is not tag v${TAG}; the chart would default images.*.tag to an image this release did not build"; exit 1; }
[[ "$VERSION" == "$TAG" ]] || { echo "::error::Chart.yaml version '${VERSION}' is not tag v${TAG}. release-please owns this line via x-release-please-version; a mismatch means the annotation was dropped or the tag was cut by hand. At v0.2.2 this published a chart numbered 0.2.1 and nothing caught it"; exit 1; }
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
# `ci.yml` does not run on tags (`push: { branches: [master] }`), so
# nothing has linted this chart since the commit that became the tag
# was a pull request. `helm lint` over the same four value sets
# `just helm-check` lints is offline and takes seconds; the rest of
# that recipe (kubeconform, the twenty-four guard fixtures) needs the
# network and belongs where it already is, on the PR.
- name: helm lint (the four value sets, as `just helm-check` does)
run: |
set -euo pipefail
helm lint deploy/helm/vpay
for f in ci/values-full.yaml ci/values-route.yaml ci/values-route-networkpolicy.yaml; do
helm lint deploy/helm/vpay -f "deploy/helm/vpay/$f"
done
# `github.actor` and the token go in through the environment rather
# than through `${{ }}` inside the script, which is zizmor's
# template-injection shape and the same way `docker/login-action`
# receives them in `build` and `merge`.
- name: log in to GHCR
env:
ACTOR: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: helm registry login "$REGISTRY" --username "$ACTOR" --password-stdin <<< "$GHCR_TOKEN"
# A SECOND login, to a different credential store, and both are needed.
# `helm registry login` above writes helm's own store
# (`HELM_REGISTRY_CONFIG`, default ~/.config/helm/registry/config.json);
# `cosign` does not read that file, it reads the docker config. So at
# v0.2.2 the chart pushed fine and the signature that follows it failed
# with `UNAUTHORIZED: unauthenticated: User cannot be authenticated with
# the token provided` against
# `POST /v2/vaam-apps/charts/vpay/blobs/uploads/` — a real, published,
# UNSIGNED chart. The `build` and `merge` jobs never hit this because
# they authenticate with `docker/login-action` and their `cosign sign`
# inherits it; this job was the only one using helm's login alone.
#
# Keep both rather than dropping the helm one: `helm push` reads
# HELM_REGISTRY_CONFIG, and whether it falls back to the docker config
# is version-dependent and not something a release path should rely on.
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# `helm push` to an OCI registry overwrites an existing version
# **silently** — measured, not assumed: pushing the same chart twice
# against a local `registry:2` returned exit 0 both times with no
# warning. Something has to refuse, and this is it.
#
# FOUR outcomes, not three. It was three until 2026-09-20, and the
# missing one cost a release. In run `35491807158` (tag `v0.2.2`) the
# push succeeded and `cosign sign` failed, leaving
# `charts/vpay:0.2.1` published and unsigned — and the three-outcome
# guard then refused every re-run, because it saw the chart that same
# run had pushed. Push-then-sign is not atomic, so a guard that cannot
# tell "somebody already released this" from "this job died halfway
# through releasing it" turns any signing failure into a release the
# pipeline cannot finish. The error message that shipped with the
# three-outcome version already guessed at this case — "either the tag
# is being re-run or that bump did not happen" — but still stopped.
#
# A signature is what tells the two apart. `cosign download signature`
# exits non-zero when there is none: verified against the real unsigned
# `charts/vpay:0.2.1`, and verified under cosign **v2.6.1**
# specifically, which is what `cosign-installer` puts on the runner.
#
# exit 0, signature found -> a real collision. Stop.
# exit 0, NO signature -> died between push and sign. Resume:
# skip the push, sign what is there.
# `... "FetchReference" ... : not found`
# -> never published (a missing version and
# a missing package are textually
# identical here and mean the same
# thing). Push.
# anything else (`dial tcp ... connection refused`, TLS, 5xx)
# -> the registry did not answer the
# question. Stop rather than push past it.
#
# The digest comes off `helm show chart`'s own stderr, which carries a
# `Digest:` line exactly as `helm push` does — verified against the
# published chart. Re-packaging to recompute it would not work:
# `helm package` is not byte-reproducible, so a second package of
# identical content hashes differently and the signature would attach
# to a copy nobody pulls.
- id: state
name: what is already published, and is it signed?
env:
CHART: oci://${{ env.REGISTRY }}/${{ env.NAMESPACE }}/charts/vpay
IMAGE: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/charts/vpay
VERSION: ${{ steps.chart.outputs.version }}
run: |
set -euo pipefail
if out="$(helm show chart "$CHART" --version "$VERSION" 2>&1)"; then
digest="$(sed -n 's/^Digest: \(sha256:[0-9a-f]\{64\}\)$/\1/p' <<< "$out" | head -1)"
if [[ -z "$digest" ]]; then
echo "::error::${CHART}:${VERSION} is published but helm printed no 'Digest:' line for it, so this job cannot tell whether it is signed. Refusing to push over it."
exit 1
fi
if cosign download signature "${IMAGE}@${digest}" > /dev/null 2>&1; then
echo "::error::${CHART}:${VERSION} is already published AND signed, and this release would otherwise overwrite a published chart in place. Since 2026-09-20 release-please bumps Chart.yaml's 'version:', so reaching this means that bump did not happen — check the annotation before bumping anything by hand."
exit 1
fi
echo "${CHART}:${VERSION} is published (${digest}) but carries no signature — a previous run died between its push and its signature. Resuming: this run signs it and does NOT push."
echo "published=true" >> "$GITHUB_OUTPUT"
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
elif ! grep -q 'not found' <<< "$out"; then
echo "::error::could not determine whether ${CHART}:${VERSION} is published — helm failed for a reason that is not 'not found', so this job will not push past it: ${out}"
exit 1
else
echo "${CHART}:${VERSION} is not published yet; proceeding to package and push"
echo "published=false" >> "$GITHUB_OUTPUT"
fi
# `helm push` prints BOTH its `Pushed:` and `Digest:` lines to
# **stderr** and leaves stdout empty — measured against a local
# `registry:2`, and the reason this redirects rather than piping
# stdout. The digest is the chart manifest's, and it is what cosign
# signs and what `docs/runbooks/release.md` tells a consumer to pin.
- id: push
name: package and push the chart
# Skipped on a resume: the chart is already in the registry, and
# re-pushing would only add a second, differently-hashed copy of
# identical content for the signature to miss.
if: steps.state.outputs.published != 'true'
env:
REPO: oci://${{ env.REGISTRY }}/${{ env.NAMESPACE }}/charts
IMAGE: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/charts/vpay
VERSION: ${{ steps.chart.outputs.version }}
run: |
set -euo pipefail
helm package deploy/helm/vpay --destination "${RUNNER_TEMP}/chart"
helm push "${RUNNER_TEMP}/chart/vpay-${VERSION}.tgz" "$REPO" 2>&1 | tee "${RUNNER_TEMP}/push.log"
digest="$(sed -n 's/^Digest: \(sha256:[0-9a-f]\{64\}\)$/\1/p' "${RUNNER_TEMP}/push.log" | head -1)"
[[ -n "$digest" ]] || { echo "::error::helm push printed no 'Digest:' line; there is nothing to sign — see the log above"; exit 1; }
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
echo "${IMAGE}:${VERSION}@${digest}" >> "$GITHUB_STEP_SUMMARY"
# Same identity, same Rekor, same caveat as the images: renaming this
# file breaks every downstream `cosign verify`.
- name: cosign sign (keyless, GitHub OIDC)
env:
IMAGE: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/charts/vpay
# Whichever step established it: the push on a normal release, the
# state probe on a resume. Exactly one of the two runs.
DIGEST: ${{ steps.push.outputs.digest || steps.state.outputs.digest }}
run: |
set -euo pipefail
[[ -n "$DIGEST" ]] || { echo "::error::no digest from either the push or the state probe; there is nothing to sign"; exit 1; }
cosign sign --yes "${IMAGE}@${DIGEST}"
echo "signed ${IMAGE}@${DIGEST}" >> "$GITHUB_STEP_SUMMARY"
# --- SDK publishing ---------------------------------------------------
#
# Two of the five directories under `sdks/` are actually ready to ship —
# see this PR's own description for the full per-SDK rundown. The other
# three are deliberately untouched by this workflow:
#
# - sdks/rust `publish = false` in Cargo.toml, on purpose:
# its own comment says the wire contract it
# implements has no server to talk to yet
# (docs/status.md). Publishing a client for an
# API nobody can reach would be actively
# misleading, not merely premature.
# - sdks/flutter `publish_to: none` in pubspec.yaml, on
# purpose: "not published yet (2026-09-13)" —
# see docs/sdks/parity.md, which as of this
# writing still records the Android/deep-link
# verification as incomplete.
# - sdks/stripe-compat `"private": true`, version pinned at 0.0.0:
# an internal parity-test shim against
# `sdks/nodejs`, never a package a merchant
# installs. `cargo xtask verify-npm-scope`
# already asserts this shape (no
# `publishConfig`, no `@vaam-apps/vpay-*` name).
#
# `sdks/nodejs` (`@vaam-apps/vpay-sdk`) and `sdks/stripe-js`
# (`@vaam-apps/vpay-stripe-js`) are both in `release-please-config.json`'s
# `extra-files`, both declare `publishConfig.access: "public"`, and both
# are exactly what `cargo xtask verify-npm-scope` already treats as
# publishable (a `dist/`-pointing `main`, a `files` allowlist, a
# `prepack` that builds). The packaging was ready; only the step that
# actually runs `npm publish` was missing.
sdk-version:
name: check SDK versions match the tag
runs-on: ubuntu-latest
outputs:
is_tag: ${{ steps.v.outputs.is_tag }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# This job never pushes; nothing here needs the checkout token to
# survive past the clone.
persist-credentials: false
# Refuse a tag pushed against manifests release-please did not
# actually bump to match. vsms's own release.yml carries the scar
# this guards against verbatim: v0.3.2 there tagged a release whose
# `sed` extraction silently returned an EMPTY string — the pattern
# was anchored to end-of-line, and every one of these lines carries
# a trailing `# x-release-please-version` comment, so `[v] == ""`
# was never true and every publish step's `if:` was skipped while
# the job itself still reported green. `[^"]*".*$`, never `.*"$`,
# for exactly that reason.
- name: tag matches the SDK manifest versions
if: startsWith(github.ref, 'refs/tags/')
run: |
set -euo pipefail
TAG="${GITHUB_REF#refs/tags/v}"
WS="$(sed -n 's/^version = "\([^"]*\)".*$/\1/p' Cargo.toml | head -1)"
NODE_SDK="$(sed -n 's/^ "version": "\(.*\)",$/\1/p' sdks/nodejs/package.json | head -1)"
STRIPE_JS_SDK="$(sed -n 's/^ "version": "\(.*\)",$/\1/p' sdks/stripe-js/package.json | head -1)"
echo "tag=${TAG} workspace=${WS} node-sdk=${NODE_SDK} stripe-js-sdk=${STRIPE_JS_SDK}"
for v in "$WS" "$NODE_SDK" "$STRIPE_JS_SDK"; do
[[ "$v" == "$TAG" ]] || { echo "::error::tag v${TAG} does not match a manifest version (see above, blank means the sed pattern matched nothing)"; exit 1; }
done
- id: v
run: echo "is_tag=${{ startsWith(github.ref, 'refs/tags/') }}" >> "$GITHUB_OUTPUT"
publish-node-sdk:
name: publish @vaam-apps/vpay-sdk to npm
needs: sdk-version
runs-on: ubuntu-latest
# Narrower than the workflow-level block (which also grants
# `packages: write` for the GHCR jobs above): this job never touches
# GHCR, only the npm registry. `id-token: write` does double duty:
# npm provenance (Sigstore/Rekor) AND the Trusted Publishing OIDC
# exchange that authenticates the publish — see the note on the
# publish step below for why those are two different things.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# This job never pushes; nothing here needs the checkout token to
# survive past the clone.
persist-credentials: false
# No `version:` here on purpose, matching `ci.yml`'s own `web`/`e2e`
# jobs: `pnpm/action-setup` refuses to run when both it and
# `package.json`'s `packageManager` name a version
# ("Multiple versions of pnpm specified"). `package.json` is this
# repository's single source of truth (pnpm@11.18.0).
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
registry-url: "https://registry.npmjs.org"
# `package-manager-cache: false`, explicitly — setup-node's own
# default is `true` (caches automatically off the manifest's
# `packageManager` field). This job publishes to a public registry
# with an OIDC-minted credential, and restoring a package-manager
# cache into that context is exactly zizmor's cache-poisoning shape:
# a cache an untrusted run could have populated, replayed into a
# privileged publish step. Runs once per release; a cold install
# costs seconds, not minutes.
package-manager-cache: false
- name: Install dependencies
working-directory: sdks/nodejs
run: pnpm install --frozen-lockfile
- name: Build, typecheck, test
working-directory: sdks/nodejs
run: |
pnpm run build
pnpm run typecheck
pnpm run test
# `pnpm pack --dry-run` — available since the pin moved to pnpm 11
# (9.15.0's `pack` had only `--json`/`--pack-destination`, which is
# why this step used to pack into the runner's scratch directory).
# Confirmed against 11.18.0's own `pack --help`, not assumed from
# docs. Matches how vsms's equivalent job does it.
- name: Package pack validation
working-directory: sdks/nodejs
run: pnpm pack --dry-run
- name: Publish dry-run
working-directory: sdks/nodejs
run: pnpm publish --dry-run --no-git-checks
# npm Trusted Publishing (OIDC). No token is read here: `setup-node`
# above sets the registry, and pnpm performs the OIDC exchange itself
# using the `id-token: write` this job already grants.
#
# The pin must be >= 11.1.3, not merely "pnpm 11". `setup-node` with
# `registry-url` writes an .npmrc containing
# `_authToken=${NODE_AUTH_TOKEN}`, and this job deliberately does not
# set that variable - so the placeholder stays unexpanded. Before
# 11.1.3 pnpm passed the literal `${NODE_AUTH_TOKEN}` through as a
# bearer token and the publish failed with a masked 404 even though
# OIDC was configured correctly (pnpm#11513). 11.1.3 treats an
# unresolved placeholder as empty, which is what lets OIDC be the
# auth source. The pin is 11.18.0, well past it.
#
# This replaced classic-token auth once BOTH of the blockers recorded
# here previously were cleared, on 2026-09-19:
#
# 1. pnpm's publish-side OIDC exchange is a pnpm 11 feature; this
# repository pinned `pnpm@9.15.0`, two majors short. The pin is
# now `pnpm@11.18.0`, matching vsms. (Not "10.21+", which an
# earlier revision of this comment claimed: v10.21.0's trusted-
# publishing entry is the install-side `trustPolicy` setting,
# not publishing. Every publish-side OIDC entry in pnpm's
# changelog is v11.x.)
#
# That pin moved in its own change, not this one, and it was
# not free: pnpm 11 refuses to install at all when a
# dependency's build script is not allow-listed, which took
# `pnpm-workspace.yaml`'s `allowBuilds` block and an
# `engines.pnpm` floor of >=11. An earlier revision of this
# comment called the bump cheap on the strength of
# `install --lockfile-only` producing a byte-identical
# lockfile; that resolves without installing, and the
# conclusion drawn from it was wrong.
# 2. Trusted Publishing cannot bootstrap a name that has never been
# published — a Trusted Publisher can only attach to a package
# that already exists. Both packages were published manually at
# 0.2.1, so there is now something to attach to.
#
# This depends on a registry-side setting that does not live in this
# repository: each package needs a Trusted Publisher on npmjs.com
# pointing at `vaam-apps/vpay` and this exact workflow filename. If
# `release.yml` is ever renamed, publishing breaks until that entry is
# repointed — the same binding vsms's own `ui` workflow comment warns
# about.
#
# `--provenance` is unchanged and orthogonal: pnpm has supported it
# since 8.4, it predates Trusted Publishing, and it needs only
# `id-token: write` plus a recognised CI provider. It attests what was
# built; OIDC authenticates who is publishing.
- name: Publish to npm (Trusted Publishing / OIDC)
if: needs.sdk-version.outputs.is_tag == 'true'
working-directory: sdks/nodejs
run: pnpm publish --access public --provenance --no-git-checks
publish-stripe-js-sdk:
name: publish @vaam-apps/vpay-stripe-js to npm
needs: sdk-version
runs-on: ubuntu-latest
# See publish-node-sdk's own comment block for why this is
# `contents: read` + `id-token: write` rather than the workflow-level
# `packages: write` too. `id-token: write` now does double duty here:
# provenance attestation AND the OIDC exchange that authenticates the
# publish.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# This job never pushes; nothing here needs the checkout token to
# survive past the clone.
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
registry-url: "https://registry.npmjs.org"
# `package-manager-cache: false`, explicitly — setup-node's own
# default is `true` (caches automatically off the manifest's
# `packageManager` field). This job publishes to a public registry
# with an OIDC-minted credential, and restoring a package-manager
# cache into that context is exactly zizmor's cache-poisoning shape:
# a cache an untrusted run could have populated, replayed into a
# privileged publish step. Runs once per release; a cold install
# costs seconds, not minutes.
package-manager-cache: false
- name: Install dependencies
working-directory: sdks/stripe-js
run: pnpm install --frozen-lockfile
- name: Build, typecheck, test
working-directory: sdks/stripe-js
run: |
pnpm run build
pnpm run typecheck
pnpm run test
# See publish-node-sdk's own pack step for why this has no
# `--dry-run` at this pnpm pin.
- name: Package pack validation
working-directory: sdks/stripe-js
run: pnpm pack --dry-run
- name: Publish dry-run
working-directory: sdks/stripe-js
run: pnpm publish --dry-run --no-git-checks
# No shared secret any more: both packages authenticate by OIDC, so
# each needs its OWN Trusted Publisher entry on npmjs.com. Unlike an
# org-scoped Automation token, a Trusted Publisher is per package.
- name: Publish to npm (Trusted Publishing / OIDC)
if: needs.sdk-version.outputs.is_tag == 'true'
working-directory: sdks/stripe-js
run: pnpm publish --access public --provenance --no-git-checks