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
name: deploy
# Prevent overlapping deploy runs for the same tag. Don't cancel
# in-progress runs — a partially-built release is worse than waiting.
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
on:
push:
tags:
# release-plz now uses per-crate tags in a workspace:
# v0.5.X (legacy, pre-split)
# enprot-v0.5.X (enprot CLI + library, post-split)
# enprot-ffi-v0.5.X (the cdylib — not deployed, no binary)
# Match the first two; enprot-ffi has no standalone binary to ship.
- 'v[0-9]*.[0-9]*.[0-9]*'
- '[0-9]*.[0-9]*.[0-9]*'
- 'enprot-v[0-9]*.[0-9]*.[0-9]*'
# Allow manual trigger to rebuild binaries for existing tags
# (e.g., v0.5.0 was tagged before the deploy pattern was fixed).
# Test builds: omit `tag` and set `ref` to a branch to exercise
# the archive matrix without publishing anything (used to iterate
# on cross-compile fixes without cutting releases — issue #368),
# optionally narrowed to one matrix leg with `target`.
workflow_dispatch:
inputs:
tag:
description: 'Tag to deploy (e.g., v0.5.0, enprot-v0.5.13). Empty = test build (no publish).'
required: false
type: string
ref:
description: 'Checkout ref override (defaults to the tag; a branch for test builds)'
required: false
type: string
target:
description: 'Build only this matrix target (e.g., x86_64-unknown-linux-musl)'
required: false
type: string
env:
CROSS_VERSION: 0.2.5
BOTAN_VERSION: 3.7.0
PROJECT_NAME: enprot
EXE_NAME: enprot
# Reproducible-build timestamp: pinned to the deploy tag's commit
# time so rustc/linker timestamps are stable across reruns (see
# docs/src/pages/docs/reference/reproducible-builds.md). Overridden per-job from git after
# checkout.
SOURCE_DATE_EPOCH: '0'
# Resolved tag for both trigger types:
# tag push → github.ref_name = "enprot-v0.5.13" or "v0.5.13"
# workflow_dispatch → inputs.tag = "enprot-v0.5.13"
# Jobs strip any non-digit prefix (both `v` and `enprot-v`) to get
# the bare version (0.5.13) via `sed 's/^[^0-9]*//'`.
# Tag pushes always carry a tag; dispatch carries one only when
# `tag` is set (empty otherwise — a test build).
DEPLOY_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event_name != 'workflow_dispatch' && github.ref_name || '' }}
# What every job checks out: an explicit ref override (test
# builds), else the tag, else the pushed ref.
DEPLOY_REF: ${{ inputs.ref || (github.event_name == 'workflow_dispatch' && inputs.tag) || github.ref_name }}
jobs:
checks:
name: Checks
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
ref: ${{ env.DEPLOY_REF }}
- name: Check version
if: env.DEPLOY_TAG != ''
run: |
set -euxo pipefail
# Strip any non-digit prefix (handles 'v', 'enprot-v', bare).
TAG="$(echo "$DEPLOY_TAG" | sed 's/^[^0-9]*//')"
if [ "$(grep '^version' Cargo.toml | cut -d '"' -f2)" != "$TAG" ]; then
echo "Version tag ($TAG) does not match manifest"
exit 1
fi
generate-extras:
name: man page + completions
needs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ env.DEPLOY_REF }}
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Restore /usr/local C-stack cache
# install.sh builds Botan + librnp (~20 min); the guards we
# added there skip both wholesale when the cache is warm.
# /usr/local on a bare ubuntu runner is tiny, so caching it
# wholesale is cheap and cannot shadow runner tooling that
# would matter (that lives in /usr, which we leave alone).
uses: actions/cache@v6
with:
path: /usr/local
key: extras-stack-${{ env.BOTAN_VERSION }}-${{ hashFiles('ci/install.sh', 'ci/build-librnp.sh') }}
- name: Install dependencies
env:
BOTAN_VERSION: ${{ env.BOTAN_VERSION }}
PREFIX: /usr/local
run: ./ci/install.sh
- name: Build enprot
env:
PKG_CONFIG_PATH: /usr/local/lib/pkgconfig
run: cargo build --features cli --release
- name: Generate man page
run: cargo run --features cli --example gen-manpage > enprot.1
- name: Generate shell completions
run: |
mkdir -p completions
target/release/enprot completions bash > completions/enprot.bash
target/release/enprot completions fish > completions/enprot.fish
target/release/enprot completions zsh > completions/_enprot
- name: Generate SBOMs
# TODO.complete/62: the binary itself emits its SBOM — Rust
# crates are baked in from Cargo.lock at build time; the C
# library versions come from the linked libraries at runtime.
# SOURCE_DATE_EPOCH (commit timestamp) keeps the documents
# byte-identical for identical releases (TODO.complete/45).
run: |
set -euxo pipefail
epoch=$(git log -1 --format=%ct)
ver=$(echo "$DEPLOY_TAG" | sed 's/^[^0-9]*//')
SOURCE_DATE_EPOCH=$epoch target/release/enprot sbom --output "enprot-${ver}.spdx.json"
SOURCE_DATE_EPOCH=$epoch target/release/enprot sbom --sbom-format cyclonedx-json --output "enprot-${ver}.cdx.json"
- name: Upload extras
uses: actions/upload-artifact@v7
with:
name: extras
path: |
enprot.1
completions/
enprot-*.spdx.json
enprot-*.cdx.json
# Emits the archive matrix as JSON — all six legs normally, or
# just the -f target=<triple> one for single-leg debug dispatches.
# The static dynamic-array + include shape cannot express this:
# include entries union all six targets back in regardless of the
# dynamic array (verified live, twice), and GHA rejects
# matrix-context job-level ifs at parse time. So the whole matrix,
# per-leg os+env included, is data in ci/deploy-legs.json.
matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.emit.outputs.matrix }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ env.DEPLOY_REF }}
- id: emit
run: |
set -eu
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.target }}" ]; then
echo "single-leg dispatch: ${{ inputs.target }}" >&2
sel=$(jq -c --arg t '${{ inputs.target }}' '{include: map(select(.target == $t))}' ci/deploy-legs.json)
if [ "$(printf '%s' "$sel" | jq '.include | length')" != "1" ]; then
echo "unknown target '${{ inputs.target }}'" >&2; exit 1
fi
echo "matrix=$sel" >> "$GITHUB_OUTPUT"
else
echo "matrix={\"include\":$(jq -c . ci/deploy-legs.json)}" >> "$GITHUB_OUTPUT"
fi
release-archive:
needs:
name: archive
# Docker cross builds compile Botan up to THREE times per leg
# (botan-sys's own vendored copy, rnp-src's copy for librnp,
# and the host librnp for bindgen); amalgamation cut the file
# count but the legs still need ~2h on 2-core runners. Every
# shorter limit (20/45/90) killed them mid-build, surfacing as
# "cancelled" at exactly the limit.
timeout-minutes: 240
strategy:
# Don't cancel native builds (macOS, Windows MSVC) when Docker
# cross-compile targets fail (blocked on upstream librnp).
fail-fast: false
# Whole matrix emitted by the `matrix` prepare job: all six legs
# normally, or just the -f target=<triple> one for single-leg
# debug dispatches. The previous dynamic-array + include shape
# could not scope — include entries union all six targets back
# in regardless of the array (verified live, twice), and GHA
# rejects matrix-context job-level ifs at parse time.
matrix: ${{ fromJSON(needs.matrix.outputs.matrix) }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ env.DEPLOY_REF }}
- name: Set environment (unix)
if: "!startsWith(matrix.os, 'windows')"
env: ${{ matrix.env }}
run: |
set -euxo pipefail
# Strip any non-digit prefix (handles 'v', 'enprot-v', bare).
TAG="$(echo "$DEPLOY_TAG" | sed 's/^[^0-9]*//')"
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "PREFIX=${PREFIX-$GITHUB_WORKSPACE/installs}" >> $GITHUB_ENV
echo "EXE_PATH=target/$TARGET/release/$EXE_NAME" >> $GITHUB_ENV
# Reproducible-build timestamp: pin to the tag's commit time.
echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> $GITHUB_ENV
- name: Set environment (windows)
if: startsWith(matrix.os, 'windows')
env: ${{ matrix.env }}
run: |
# Strip any non-digit prefix (handles 'v', 'enprot-v', bare).
$Tag = $Env:DEPLOY_TAG -replace '^[^0-9]+',''
"RELEASE_TAG=$Tag" | Out-File -Append -Encoding UTF8 -LiteralPath $Env:GITHUB_ENV
$Prefix = (Join-Path $PWD 'installs').Replace('\', '/')
"PREFIX=$Prefix" | Out-File -Append -Encoding UTF8 -LiteralPath $Env:GITHUB_ENV
"EXE_PATH=target/$Env:TARGET/release/$Env:EXE_NAME" | Out-File -Append -Encoding UTF8 -LiteralPath $Env:GITHUB_ENV
# Reproducible-build timestamp: pin to the tag's commit time.
$CommitEpoch = git log -1 --format=%ct
"SOURCE_DATE_EPOCH=$CommitEpoch" | Out-File -Append -Encoding UTF8 -LiteralPath $Env:GITHUB_ENV
- name: Install rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
target: ${{ matrix.env.TARGET }}
- name: Rust toolchain fingerprint
# Fingerprints are only stable for a given rustc; key the
# cache on it so toolchain rotation means a cold rebuild.
shell: bash
run: echo "v=$(rustc -vV | shasum -a 256 | cut -c1-12)" >> "$GITHUB_OUTPUT"
id: rc
- name: Restore cross C-stack cache
# The vendored C stack (Botan x3, librnp, json-c, sexpp,
# zlib, bzip2) lives inside cargo build-script OUT_DIRs
# under target/. Cache exactly those trees plus their
# fingerprints: cargo then skips the build scripts outright
# (~90 min per leg on 2-core runners) and only recompiles
# the Rust side. Rust rlibs are deliberately NOT cached —
# they dominate the cache size for far less wall time.
# Strictly keyed per (target, Cargo.lock, rustc, ci files,
# this workflow) so a change in any input — including the
# BOTAN_CONFIGURE_* matrix values — is a cold rebuild, and
# legs never see another leg's artifacts.
uses: actions/cache@v6
with:
path: |
target/release/build/botan-sys-*/out
target/release/build/rnp-src-*/out
target/release/.fingerprint/botan-sys-*
target/release/.fingerprint/rnp-src-*
target/*/release/build/botan-sys-*/out
target/*/release/build/rnp-src-*/out
target/*/release/build/ring-*/out
target/*/.fingerprint/botan-sys-*
target/*/.fingerprint/rnp-src-*
key: cross-${{ matrix.target }}-${{ hashFiles('Cargo.lock') }}-${{ steps.rc.outputs.v }}-${{ hashFiles('ci/**', '.cargo/**', '.github/workflows/deploy.yml') }}
- name: Install python (windows)
if: startsWith(matrix.os, 'windows')
uses: actions/setup-python@v7
with:
python-version: '3.x'
- name: Restore MSVC C-stack cache
# install.ps1 builds the full C stack into ./installs
# (~30 min); its $stackCacheHit guard skips it all when
# warm. Keyed on the script + Botan version only.
if: startsWith(matrix.os, 'windows')
uses: actions/cache@v6
with:
path: installs
key: msvc-stack-${{ matrix.env.BOTAN_VERSION || env.BOTAN_VERSION }}-${{ hashFiles('ci/install.ps1') }}
- name: Install dependencies (windows)
if: startsWith(matrix.os, 'windows')
env:
GIT_REDIRECT_STDERR: '2>&1'
shell: pwsh
run: |
$Env:Path += ";$Env:PREFIX/lib"
$Env:TARGET = "x86_64-pc-windows-msvc"
./ci/install.ps1
- name: Build (unix)
if: "!startsWith(matrix.os, 'windows')"
env: ${{ matrix.env }}
run: |
set -euxo pipefail
. ci/build-static.sh
- name: Build (windows)
if: startsWith(matrix.os, 'windows')
env: ${{ matrix.env }}
shell: pwsh
run: |
. .\ci\build-static.ps1
- name: Verify binary (windows)
if: startsWith(matrix.os, 'windows')
shell: pwsh
run: |
$v = & "$Env:EXE_PATH" --version
if ($v -notmatch "enprot") { throw "Binary verification failed: $v" }
- name: Download extras
uses: actions/download-artifact@v8
with:
name: extras
path: extras
- name: Archive (unix)
if: "!startsWith(matrix.os, 'windows')"
env: ${{ matrix.env }}
run: |
set -euxo pipefail
. ci/archive.sh
- name: Archive (windows)
if: startsWith(matrix.os, 'windows')
env: ${{ matrix.env }}
shell: pwsh
run: |
Set-StrictMode -Version 3.0
. .\ci\archive.ps1
- name: Upload artifacts
# One artifact per matrix leg: parallel uploads to a single
# name collide (only the first leg's files survive), which
# shipped releases with one asset instead of six. The
# download step merges the per-leg artifacts back together.
uses: actions/upload-artifact@v7
with:
name: archives-${{ matrix.target }}
path: archives
github-release:
name: github release
needs:
# Run even if some archive targets failed (e.g., Docker cross-compile
# targets blocked on upstream librnp). Publish whatever archives
# succeeded so users get macOS + Windows MSVC binaries.
# Test builds (dispatch without `tag`) must not publish.
if: always() && !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.tag != '')
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v8
with:
pattern: archives-*
merge-multiple: true
# The extras artifact (man page, completions, SBOMs) was
# uploaded by generate-extras but never downloaded here —
# releases shipped binaries only. Pull it into the same
# upload set.
- name: Download extras
uses: actions/download-artifact@v8
with:
name: extras
path: extras
- name: Flatten extras into the upload set
# gh release upload takes FILES only — a directory argument
# aborts the whole upload ("read <dir>: is a directory"),
# which clobbered v0.5.59 down to 3 assets mid-failure.
# Completions ship as one tarball; man page and SBOMs are
# files already.
run: |
set -euxo pipefail
mv extras/enprot.1 .
mv extras/enprot-*.spdx.json extras/enprot-*.cdx.json . 2>/dev/null || true
comp=$(ls extras/completions 2>/dev/null | wc -l)
if [ "$comp" -gt 0 ]; then
tar -C extras -czf "enprot-completions.tar.gz" completions
fi
rm -rf extras
# The upload step globs '*' at the top level — nothing
# directory-shaped may remain there.
if find . -mindepth 1 -maxdepth 1 -type d | grep -q .; then
echo "directory in upload set:"; find . -mindepth 1 -maxdepth 1 -type d; exit 1
fi
- name: Upload release assets
# release-plz already created the release object (the tag is
# its); this step only attaches artifacts. The previous
# Ruby-based create-github-release helper broke on current
# runners (bundler/rubygems mismatch) — gh does the same job
# with one dependency the workflow already trusts.
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euxo pipefail
# Create-if-missing (manual dispatch for a pre-release-plz
# tag); idempotent otherwise. --clobber makes re-dispatching
# a tag safe.
gh release view "$DEPLOY_TAG" -R "$GITHUB_REPOSITORY" ||
gh release create "$DEPLOY_TAG" -R "$GITHUB_REPOSITORY" \
--title "$DEPLOY_TAG" --generate-notes
if compgen -G "*" > /dev/null; then
gh release upload "$DEPLOY_TAG" -R "$GITHUB_REPOSITORY" --clobber *
else
echo "no archive artifacts to upload"
fi
publish-snap:
name: snap
needs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ env.DEPLOY_REF }}
# snapcore/action-build@v1 invokes snapcraft as part of its
# action — it needs the version replaced BEFORE this step.
# Step is named "build" so ${{ steps.build.outputs.snap }} works.
- name: Set snap version
run: |
# Strip any non-digit prefix (handles 'v', 'enprot-v', bare).
RELEASE_TAG="$(echo "$DEPLOY_TAG" | sed 's/^[^0-9]*//')"
sed -i "s/RELEASE_TAG/${RELEASE_TAG}/g" snap/snapcraft.yaml
- name: Install snapcraft
id: build
uses: snapcore/action-build@v1
with:
snapcraft_token: ${{ secrets.SNAPCRAFT_RELEASE_LOGIN }}
- name: Install review-tools
run: sudo snap install review-tools
- name: Publish
# Direct upload: snapcore/action-publish@v1 is obsolete against
# the snapcraft on current runners — it logs in via
# 'snapcraft login --with <file>', which snapcraft 8+ removed
# ("use SNAPCRAFT_STORE_CREDENTIALS instead", exit 64). The
# env var accepts the same export-login payload the org
# secret carries.
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_RELEASE_LOGIN }}
run: snapcraft upload --release=stable "${{ steps.build.outputs.snap }}"