-
Notifications
You must be signed in to change notification settings - Fork 0
635 lines (588 loc) · 28.8 KB
/
Copy pathrelease.yml
File metadata and controls
635 lines (588 loc) · 28.8 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
# Reproducible & verifiable release pipeline (task #383).
#
# Triggered on tag push (`v*`). Builds the void-client bundle and the
# api-server Docker image, emits a single SHA256SUMS asset covering both,
# cosign-keyless-signs it (no long-lived key custody), attaches SLSA
# provenance, and runs a second job in a clean container that rebuilds
# from the same SHA and asserts the SHA256SUMS file is byte-identical.
#
# Verification recipe for an external reader is in README-selfhost.md.
#
# What this workflow does NOT try to do:
# - Reproduce the Docker IMAGE digest across all build hosts. The
# published image digest is "what the canonical CI builder said",
# called out honestly in the README. Reproducing the void-client
# bundle bytes across hosts is the actual reproducibility claim.
#
# Onion bake (Option A — canonical-instance-specific sums):
# The void-client is built at NODE_ENV=production, which turns ON the
# fail-closed onion-bake guard in artifacts/void-client/vite.config.ts
# (assertOnionBake). That guard requires a valid v3 `.onion` host in
# VITE_VOID_ONION_HOST, or the build fails closed rather than shipping a
# silently-inert "Tor-reachable" bundle. We source the canonical address
# from the `VITE_VOID_ONION_HOST` repo VARIABLE (not a secret — the
# address is public: it ships in the README and the page footer) and
# inject the IDENTICAL value into EVERY production build step below (the
# pnpm build, the Docker image build's frontend stage, and both
# reproducibility rebuilds) so byte-identity still holds.
#
# PUBLIC_ORIGIN (same treatment): the OG page generator
# (artifacts/void-client/scripts/gen-og-pages.mjs) fails closed under
# NODE_ENV=production if neither PUBLIC_ORIGIN nor REPLIT_DOMAINS is set,
# and the absolute origin it bakes into the social-card HTML changes the
# bundle bytes. So we source it from the public `PUBLIC_ORIGIN` repo
# VARIABLE and inject the IDENTICAL value into every production build step
# below, exactly like VITE_VOID_ONION_HOST, or the reproducibility diff
# fails for the wrong reason.
#
# Consequence, stated honestly: baking the canonical `.onion` makes the
# published, signed SHA256SUMS.void-client SPECIFIC TO THE CANONICAL
# INSTANCE's build. A self-hoster who rebuilds with their own onion (or
# none) produces different bytes and will NOT match these sums — by
# design (see README-selfhost.md §7a, Posture A vs B). The
# reproducibility claim here is "the canonical CI build is deterministic
# against ITSELF (same source SHA + same onion variable → byte-identical
# rebuild)", not "every self-hoster's build matches ours".
#
# Prerequisite / ordering: the canonical hidden-service key (and thus the
# `.onion` address) must exist BEFORE the first release runs; its address
# is stored in the repo variable, then the release builds around that
# fixed address. If the variable is empty the guard correctly fails the
# release closed (README-selfhost.md §6d). Do NOT weaken assertOnionBake
# to make a red build green — satisfy the guard, never disarm it.
name: release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to use when run manually"
required: true
permissions:
contents: write # release asset upload
id-token: write # cosign keyless + actions/attest-build-provenance
attestations: write
packages: write
env:
PINNED_NODE_VERSION: "22.12.0"
PINNED_PNPM_VERSION: "10.26.1"
# The Tor v3 .onion mirror host baked into the canonical production
# void-client bundle. Sourced from a repository VARIABLE (not a secret — the
# address is public: it ships in the README and the page footer). This is the
# SINGLE source of truth threaded into every production build path below
# (build-and-sign, reproducibility-check, reproducibility-check-arm64) so all
# of them bake the IDENTICAL address and the byte-for-byte reproducibility
# diff holds. Keep it in lockstep the same way the corepack pin is kept in
# lockstep across Dockerfile / README-selfhost.md. When this variable is unset
# the onion-bake guard (artifacts/void-client/src/lib/onionHost.ts, wired in
# vite.config.ts) fails the NODE_ENV=production build CLOSED rather than
# shipping a "Tor-reachable" bundle whose onion link is silently inert. Set it
# to the deployment's <56-char-base32>.onion host (see README-selfhost.md §6e
# Step 2 and §7a) to publish the onion-baked release.
VITE_VOID_ONION_HOST: ${{ vars.VITE_VOID_ONION_HOST }}
jobs:
# ---------------------------------------------------------------------
# Job 0: preflight — assert the required build VARIABLES are set and
# well-formed BEFORE any build step runs. Both VITE_VOID_ONION_HOST and
# PUBLIC_ORIGIN are baked into the production bundle bytes; if either is
# unset the NODE_ENV=production build correctly fails closed, but that
# failure surfaces deep inside the onion-bake guard (onionHost.ts, wired in
# vite.config.ts) or gen-og-pages.mjs several steps later, with a cryptic
# exit. This job turns "you forgot to set PUBLIC_ORIGIN" into a fast,
# self-explanatory failure. build-and-sign `needs:` this job, and the
# reproducibility jobs `needs:` build-and-sign, so the whole pipeline gates
# on the preflight.
preflight:
name: Preflight (required build variables)
runs-on: ubuntu-latest
# Canonical-repo only, matching build-and-sign so forks don't run it.
if: ${{ github.event.repository.fork == false }}
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
# The onion-host / PUBLIC_ORIGIN validation lives in a single shared
# script (scripts/preflight-build-vars.mjs) that both this job and the
# void-client-sri.yml preflight invoke, so the two can never drift. It
# reuses the same validators the build itself relies on (onionHost.ts,
# originRules.mjs). --experimental-strip-types lets it import the .ts
# validator on the .nvmrc Node version.
- name: Assert VITE_VOID_ONION_HOST and PUBLIC_ORIGIN are set and valid
env:
VITE_VOID_ONION_HOST: ${{ vars.VITE_VOID_ONION_HOST }}
PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }}
run: node --experimental-strip-types scripts/preflight-build-vars.mjs
# ---------------------------------------------------------------------
# Job 1: build the released bundle, emit SHA256SUMS, sign it, attest it.
# ---------------------------------------------------------------------
build-and-sign:
name: Build, hash, sign
needs: preflight
runs-on: ubuntu-latest
# Canonical-repo only. Forks inherit this workflow but must not run the
# release pipeline (cosign keyless signing, SLSA provenance, release-asset
# upload). `github.event.repository.fork` is false on the canonical repo
# and true on forks; it is populated for tag-push and workflow_dispatch
# events. Downstream jobs `needs:` this one, so they skip on forks too.
if: ${{ github.event.repository.fork == false }}
timeout-minutes: 25
outputs:
release_tag: ${{ steps.tag.outputs.release_tag }}
git_sha: ${{ steps.tag.outputs.git_sha }}
steps:
# The required-build-variable preflight runs once in the dedicated
# `preflight` job above (which this job `needs:`), using the shared
# scripts/preflight-build-vars.mjs so there is a single source of truth.
# No inline copy here — that job already gates this one.
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve release tag and git SHA
id: tag
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ inputs.release_tag }}"
else
TAG="${GITHUB_REF#refs/tags/}"
fi
echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "git_sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
- name: Set up pnpm (pinned by version)
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Set up Node.js (pinned by version)
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Toolchain drift gate (engines vs runner)
# The most common way pinning silently rots is when the runner
# actually installs a different Node/pnpm than what package.json
# declares. Fail hard before we build anything if the manifest
# and the runner disagree.
run: |
set -euo pipefail
declared_node=$(node -e "console.log(require('./package.json').engines.node)")
actual_node=$(node --version | sed 's/^v//')
if [ "$declared_node" != "$actual_node" ]; then
echo "::error::engines.node='${declared_node}' but runner has node='${actual_node}'"
exit 1
fi
declared_pm=$(node -e "console.log(require('./package.json').packageManager)")
actual_pm="pnpm@$(pnpm --version)"
if [ "$declared_pm" != "$actual_pm" ]; then
echo "::error::packageManager='${declared_pm}' but runner has '${actual_pm}'"
exit 1
fi
echo "Toolchain pin OK: node=${actual_node}, ${actual_pm}"
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile --prefer-offline
- name: Build void-client (production)
env:
NODE_ENV: production
PORT: "3000"
BASE_PATH: /
# Canonical `.onion` address baked into the bundle. Sourced from
# the public repo VARIABLE (not a secret). Under NODE_ENV=production
# the onion-bake guard fails closed if this is unset/malformed, so
# an empty variable stops the release by design (see header + README
# §6d). Must be IDENTICAL to the value used by the reproducibility
# rebuild below or the byte-for-byte diff fails.
VITE_VOID_ONION_HOST: ${{ vars.VITE_VOID_ONION_HOST }}
# Absolute origin baked into the social-card OG pages. Also a public
# repo VARIABLE. Under NODE_ENV=production gen-og-pages fails closed
# if neither PUBLIC_ORIGIN nor REPLIT_DOMAINS is set. Like the onion
# host, this value is baked into the bundle bytes, so it MUST be
# IDENTICAL across every build step here or the reproducibility diff
# fails.
PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }}
run: pnpm --filter @workspace/void-client run build
- name: Build api-server with build-time provenance injection
env:
GIT_SHA: ${{ steps.tag.outputs.git_sha }}
RELEASE_TAG: ${{ steps.tag.outputs.release_tag }}
BUILD_TIMESTAMP: ${{ github.event.head_commit.timestamp }}
CLIENT_DIST_DIR: artifacts/void-client/dist/public
run: pnpm --filter @workspace/api-server run build
- name: Promote provenance.json to release-asset path
run: |
set -euo pipefail
cp artifacts/api-server/dist/provenance.json provenance.json
echo "provenance.json preview:"
jq '{schemaVersion, commit, builtAt, builder, releaseTag, sriDigestCount: (.sriDigests | length)}' provenance.json
EMITTED_SHA=$(jq -r .commit provenance.json)
if [ "$EMITTED_SHA" != "${{ steps.tag.outputs.git_sha }}" ]; then
echo "::error::provenance.json commit='${EMITTED_SHA}' does not match release SHA='${{ steps.tag.outputs.git_sha }}'."
exit 1
fi
EMITTED_FILES=$(jq '.sriDigests | length' provenance.json)
if [ "$EMITTED_FILES" -lt 1 ]; then
echo "::error::provenance.json sriDigests is empty - CLIENT_DIST_DIR was not honored by build.mjs."
exit 1
fi
- name: Generate SHA256SUMS.void-client (the reproducibility claim)
# First-class release asset. Sorted, LC_ALL=C, relative paths,
# LF newlines. The reproducibility-check job asserts THIS FILE
# is byte-identical on a clean rebuild. The scope of the
# reproducibility claim is the void-client bundle — what the
# browser actually executes. The Docker image digest is recorded
# separately and is "what the canonical CI builder said"; we do
# not claim per-host image reproducibility (see README §7a).
run: |
set -euo pipefail
cd artifacts/void-client/dist/public
LC_ALL=C find . -type f -print0 \
| LC_ALL=C sort -z \
| xargs -0 sha256sum \
> "${GITHUB_WORKSPACE}/SHA256SUMS.void-client"
cd "${GITHUB_WORKSPACE}"
echo "SHA256SUMS.void-client preview:"
head -20 SHA256SUMS.void-client
- name: Assert Docker base image digest pin matches source of truth
# The Dockerfile FROM lines and .docker-base-digest MUST agree.
# This is what makes the released image bytes pinnable across
# arbitrary rebuilds — the source-of-truth digest is committed,
# not resolved from a floating tag at release time.
run: |
set -euo pipefail
IMAGE_TAG=$(grep -E '^image=' .docker-base-digest | cut -d= -f2)
DIGEST=$(grep -E '^digest=' .docker-base-digest | cut -d= -f2)
if [ -z "$DIGEST" ] || ! [[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "::error::.docker-base-digest digest= is empty or malformed: '${DIGEST}'"
exit 1
fi
EXPECTED_FROM="FROM ${IMAGE_TAG}@${DIGEST}"
FROM_COUNT=$(grep -cE "^FROM ${IMAGE_TAG}@${DIGEST}( |\$)" Dockerfile || true)
if [ "$FROM_COUNT" -lt 2 ]; then
echo "::error::Dockerfile FROM lines do not match .docker-base-digest pin."
echo "::error::Expected at least 2 lines starting: ${EXPECTED_FROM}"
grep -nE '^FROM ' Dockerfile || true
exit 1
fi
echo "Docker base pin OK: ${IMAGE_TAG}@${DIGEST}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build api-server Docker image (with build-arg provenance)
# The Docker build is the canonical artifact. We pass GIT_SHA /
# RELEASE_TAG / BUILD_TIMESTAMP as build-args so the BUILD_INFO.json
# baked into the image's dist/ matches what the running server
# serves at /api/proof/build. Without these the image's
# build.mjs would fall back to "unknown" (the .git dir is in
# .dockerignore) and the endpoint would lie.
id: docker_build
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: void-app:${{ steps.tag.outputs.release_tag }}
provenance: true
# VITE_VOID_ONION_HOST is required here too: the Dockerfile's
# `frontend` stage rebuilds the void-client at NODE_ENV=production
# (default ARG), so it hits the same fail-closed onion-bake guard.
# Pass the identical canonical address so the client baked into the
# image matches the pnpm-built bundle above (and its per-file
# sha256 map in the image's BUILD_INFO.json).
build-args: |
GIT_SHA=${{ steps.tag.outputs.git_sha }}
GIT_SHA_SHORT=${{ steps.tag.outputs.git_sha }}
RELEASE_TAG=${{ steps.tag.outputs.release_tag }}
BUILD_TIMESTAMP=${{ github.event.head_commit.timestamp }}
VITE_VOID_ONION_HOST=${{ vars.VITE_VOID_ONION_HOST }}
PUBLIC_ORIGIN=${{ vars.PUBLIC_ORIGIN }}
outputs: type=docker,name=void-app:${{ steps.tag.outputs.release_tag }}
- name: Capture api-server image digest and verify embedded BUILD_INFO
# Read BUILD_INFO.json out of the built image to prove the image
# actually contains the provenance we expect. Fails the release
# if gitSha is "unknown" — that means the build-args path is
# silently broken (e.g. someone reverted the Dockerfile ARGs).
id: image_digest
run: |
set -euo pipefail
IMAGE_REF="void-app:${{ steps.tag.outputs.release_tag }}"
IMAGE_DIGEST=$(docker image inspect "$IMAGE_REF" --format '{{.Id}}')
echo "image_digest=${IMAGE_DIGEST}" >> "$GITHUB_OUTPUT"
echo "${IMAGE_DIGEST} ${IMAGE_REF}" > SHA256SUMS.api-server
# Confirm BUILD_INFO.json embedded into the image matches the
# release we are about to publish.
CID=$(docker create "$IMAGE_REF")
docker cp "$CID:/app/BUILD_INFO.json" embedded-BUILD_INFO.json
docker rm "$CID" > /dev/null
EMBEDDED_SHA=$(jq -r .gitSha embedded-BUILD_INFO.json)
if [ "$EMBEDDED_SHA" != "${{ steps.tag.outputs.git_sha }}" ]; then
echo "::error::Embedded BUILD_INFO.json gitSha='${EMBEDDED_SHA}' does not match release SHA='${{ steps.tag.outputs.git_sha }}'."
echo "::error::Most likely cause: --build-arg GIT_SHA was not threaded through the Docker build."
exit 1
fi
EMBEDDED_FILES=$(jq '.sha256sums | length' embedded-BUILD_INFO.json)
if [ "$EMBEDDED_FILES" -lt 1 ]; then
echo "::error::Embedded BUILD_INFO.json sha256sums is empty — CLIENT_DIST_DIR was not honored in the Docker build."
exit 1
fi
echo "Embedded BUILD_INFO.json OK: gitSha=${EMBEDDED_SHA}, files=${EMBEDDED_FILES}"
cat SHA256SUMS.api-server
- name: Compose final SHA256SUMS
run: |
set -euo pipefail
{
echo "# Reproducible build hashes for ${{ steps.tag.outputs.release_tag }}"
echo "# git: ${{ steps.tag.outputs.git_sha }}"
echo "# void-client: sha256 of every file under dist/public/"
echo "# api-server: Docker image ID (canonical builder digest)"
echo ""
echo "## void-client/dist/public"
cat SHA256SUMS.void-client
echo ""
echo "## api-server image"
cat SHA256SUMS.api-server
} > SHA256SUMS
sha256sum SHA256SUMS
# Task #491 / M-7 — Generate a CycloneDX SBOM for the workspace
# (every package under artifacts/ and lib/, transitive deps
# resolved against the frozen lockfile). This runs after the
# void-client and api-server builds so the dependency graph is
# the same one those artifacts were built against.
- name: Generate SBOM (CycloneDX, JSON)
run: |
set -euo pipefail
pnpm dlx cyclonedx-pnpm@2 \
--output-format JSON \
--output-file sbom.cdx.json
echo "SBOM components: $(jq '.components | length' sbom.cdx.json)"
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Cosign keyless-sign SHA256SUMS
# Keyless via GitHub OIDC — no long-lived key custody. The
# certificate identity is bound to this workflow run, which is
# what the verify command checks against. See README-selfhost.md.
run: |
set -euo pipefail
cosign sign-blob --yes \
--output-signature SHA256SUMS.sig \
--output-certificate SHA256SUMS.pem \
SHA256SUMS
# Task #491 / M-7 — Sign the SBOM with the same keyless cosign
# flow used above for SHA256SUMS. Same workflow run, same OIDC
# identity, so verifiers can reuse the existing verify recipe in
# README-selfhost.md against `sbom.cdx.json{.sig,.pem}`.
- name: Cosign keyless-sign SBOM
run: |
set -euo pipefail
cosign sign-blob --yes \
--output-signature sbom.cdx.json.sig \
--output-certificate sbom.cdx.json.pem \
sbom.cdx.json
# Task #491 / M-6 - Sign provenance.json with the same keyless
# cosign flow as SHA256SUMS / sbom.cdx.json. Same workflow run,
# same OIDC identity, same verify recipe in README-selfhost.md.
- name: Cosign keyless-sign provenance.json
run: |
set -euo pipefail
cosign sign-blob --yes \
--output-signature provenance.json.sig \
--output-certificate provenance.json.pem \
provenance.json
- name: Attach SLSA build provenance (void-client bundle)
uses: actions/attest-build-provenance@v2
with:
subject-path: artifacts/void-client/dist/public/**/*
- name: Attach SLSA build provenance (SHA256SUMS)
uses: actions/attest-build-provenance@v2
with:
subject-path: SHA256SUMS
- name: Attach SLSA build provenance (provenance.json)
uses: actions/attest-build-provenance@v2
with:
subject-path: provenance.json
- name: Attach SLSA build provenance (api-server Docker image)
# Image provenance is attached by digest so the attestation
# follows the bytes, not a mutable tag. The image digest came
# from `docker image inspect` above on the freshly-built image.
uses: actions/attest-build-provenance@v2
with:
subject-name: void-app
subject-digest: ${{ steps.image_digest.outputs.image_digest }}
- name: Upload release artifacts (workflow)
uses: actions/upload-artifact@v4
with:
name: release-${{ steps.tag.outputs.release_tag }}
path: |
SHA256SUMS
SHA256SUMS.void-client
SHA256SUMS.sig
SHA256SUMS.pem
provenance.json
provenance.json.sig
provenance.json.pem
sbom.cdx.json
sbom.cdx.json.sig
sbom.cdx.json.pem
.docker-base-digest
if-no-files-found: error
retention-days: 90
- name: Attach to GitHub Release
if: github.event_name == 'push'
uses: softprops/action-gh-release@v2
with:
files: |
SHA256SUMS
SHA256SUMS.void-client
SHA256SUMS.sig
SHA256SUMS.pem
provenance.json
provenance.json.sig
provenance.json.pem
sbom.cdx.json
sbom.cdx.json.sig
sbom.cdx.json.pem
.docker-base-digest
fail_on_unmatched_files: true
# ---------------------------------------------------------------------
# Job 2: clean-room rebuild, must produce byte-identical SHA256SUMS.
# ---------------------------------------------------------------------
reproducibility-check:
name: Reproducibility check (clean container)
needs: build-and-sign
runs-on: ubuntu-latest
timeout-minutes: 25
container:
image: node:22.12.0-slim
steps:
- name: Install build deps in the clean container
run: |
set -euo pipefail
apt-get update
apt-get install -y --no-install-recommends git ca-certificates curl xxd
rm -rf /var/lib/apt/lists/*
# node:22.12.0-slim bundles corepack 0.29.4, which predates pnpm's
# npm registry signing-key rotation and fails `corepack prepare`
# with "Cannot find matching keyid". Pin a corepack carrying the
# rotated keys (updated upstream in 0.31.0) first, keeping signature
# verification intact. Must match the Dockerfile's corepack pin.
npm install -g corepack@0.34.5
corepack enable
corepack prepare pnpm@10.26.1 --activate
- name: Checkout the same SHA
uses: actions/checkout@v4
with:
ref: ${{ needs.build-and-sign.outputs.git_sha }}
- name: Install dependencies (frozen lockfile)
run: pnpm install --frozen-lockfile --prefer-offline
- name: Build void-client (clean rebuild)
env:
NODE_ENV: production
PORT: "3000"
BASE_PATH: /
# Same canonical `.onion` variable as the build-and-sign job. The
# onion address is a build input that changes the bundle bytes, so
# the clean rebuild MUST use the identical value or the
# byte-for-byte diff below fails. This job is what enforces "the
# canonical build is deterministic against itself".
VITE_VOID_ONION_HOST: ${{ vars.VITE_VOID_ONION_HOST }}
# Same public-origin variable as build-and-sign. Also a bundle-byte
# input (OG pages), so the clean rebuild MUST use the identical value
# or the byte-for-byte diff below fails.
PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }}
run: pnpm --filter @workspace/void-client run build
- name: Recompute SHA256SUMS.void-client (clean rebuild)
run: |
set -euo pipefail
cd artifacts/void-client/dist/public
LC_ALL=C find . -type f -print0 \
| LC_ALL=C sort -z \
| xargs -0 sha256sum \
> "${GITHUB_WORKSPACE}/SHA256SUMS.void-client.rebuild"
- name: Download release-job assets
uses: actions/download-artifact@v4
with:
name: release-${{ needs.build-and-sign.outputs.release_tag }}
path: release-asset/
- name: Diff-assert SHA256SUMS.void-client byte-identical
# The first-class SHA256SUMS.void-client release asset is the
# subject of the reproducibility claim. We diff it as a whole
# file (no section extraction) so any divergence — ordering,
# locale, line endings, extra/missing files, hash changes —
# surfaces in one diff.
run: |
set -euo pipefail
if ! diff -u release-asset/SHA256SUMS.void-client SHA256SUMS.void-client.rebuild; then
echo "::error::Reproducibility check FAILED — clean rebuild does not match released SHA256SUMS.void-client byte-for-byte."
echo "::error::Likely causes: build-step nondeterminism (timestamps, source ordering, locale), toolchain drift, lockfile drift."
exit 1
fi
echo "Reproducibility check PASSED — clean rebuild matches the released SHA256SUMS.void-client byte-for-byte."
# ---------------------------------------------------------------------
# Job 3 (optional, best-effort): arm64 rebuild for the Pi-class targets
# that Umbrel / StartOS commonly run on. Skipped on PRs to save minutes.
# ---------------------------------------------------------------------
reproducibility-check-arm64:
name: Reproducibility check (arm64)
needs: build-and-sign
runs-on: ubuntu-24.04-arm
timeout-minutes: 35
if: ${{ github.event_name == 'push' }}
continue-on-error: true
steps:
- name: Checkout the same SHA
uses: actions/checkout@v4
with:
ref: ${{ needs.build-and-sign.outputs.git_sha }}
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: Build void-client (arm64)
env:
NODE_ENV: production
PORT: "3000"
BASE_PATH: /
# Same canonical `.onion` variable as the other build steps. This
# job is advisory (continue-on-error above) and does not block the
# release on its own, but it must use the identical value so its
# diff against the released SHA256SUMS stays meaningful — otherwise
# a mismatch would always fire for the wrong reason (missing onion,
# not an arch difference).
VITE_VOID_ONION_HOST: ${{ vars.VITE_VOID_ONION_HOST }}
# Same public-origin variable as the other build steps, for the same
# reason: an OG-baked bundle input that must match to keep this
# advisory diff meaningful.
PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }}
run: pnpm --filter @workspace/void-client run build
- name: Recompute and diff
run: |
set -euo pipefail
cd artifacts/void-client/dist/public
LC_ALL=C find . -type f -print0 \
| LC_ALL=C sort -z \
| xargs -0 sha256sum \
> "${GITHUB_WORKSPACE}/SHA256SUMS.void-client.arm64"
cd "${GITHUB_WORKSPACE}"
echo "arm64 SHA256SUMS preview:"
head -10 SHA256SUMS.void-client.arm64
- name: Download release-job assets
uses: actions/download-artifact@v4
with:
name: release-${{ needs.build-and-sign.outputs.release_tag }}
path: release-asset/
- name: Diff against released SHA256SUMS.void-client
run: |
set -euo pipefail
if ! diff -u release-asset/SHA256SUMS.void-client SHA256SUMS.void-client.arm64; then
echo "::warning::arm64 rebuild does NOT match x86_64 release. This is informational — Pi-class users get a separately-built artifact and the canonical published reference is x86_64. Investigate if you intend to publish arm64 images."
exit 1
fi