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
name: Release WASM
on:
push:
tags:
permissions:
contents: write
attestations: write
id-token: write
# Prevent two simultaneous tag pushes from racing the publish steps.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
# Toolchain used to BUILD and PUBLISH the release. This is intentionally newer
# than the crate's declared MSRV (`rust-version = "1.85"` in Cargo.toml) so the
# release tooling (cargo-audit, cargo-cyclonedx) installs cleanly — those tools
# raise their own MSRV over time. MSRV compatibility is still verified in CI.
RUST_TOOLCHAIN: "1.90"
WASM_PACK_VERSION: "0.14.0"
jobs:
release:
runs-on: ubuntu-latest
# Scopes the crates.io and npm OIDC trusted-publishing credentials to a
# dedicated, protectable environment (configure under repo Settings →
# Environments). The trusted-publisher config on crates.io/npmjs must use
# this same environment name.
environment: release
steps:
# Third-party actions are pinned to a full commit SHA (SLSA L3 / federal
# supply-chain requirement). Version comments are kept for Dependabot,
# which can bump both the SHA and the comment.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
targets: wasm32-unknown-unknown
components: clippy, rustfmt
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
# --- Tag/version guard ---
# The release is triggered by a `v*` tag, but `cargo publish` reads the
# version from Cargo.toml. If they disagree (e.g. the tag was pushed
# before bumping Cargo.toml), `cargo publish` fails deep into the job with
# "crate already exists", AFTER the crates.io step but BEFORE npm — leaving
# a half-published, inconsistent release. Fail fast here instead.
- name: Verify Cargo.toml version matches tag
run: |
tag="${GITHUB_REF_NAME#v}"
crate="$(cargo metadata --no-deps --format-version 1 \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])')"
echo "tag=${tag} Cargo.toml=${crate}"
if [ "${tag}" != "${crate}" ]; then
echo "::error::Tag v${tag} does not match Cargo.toml version ${crate}. Bump Cargo.toml (and Cargo.lock) before tagging."
exit 1
fi
# --- Quality gates ---
- name: Format check
run: cargo fmt --check
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
- name: Tests
run: cargo test
# Re-check RustSec advisories at release time (CI already runs this on
# every push/PR; gating the release too prevents shipping a tag built
# from a commit that predates a newly disclosed advisory).
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: Audit dependencies
run: cargo audit
# --- Publish to crates.io ---
# Trusted publishing (OIDC): exchanges the workflow's short-lived GitHub
# OIDC token for a temporary crates.io token. No long-lived
# CARGO_REGISTRY_TOKEN secret is stored anywhere.
# One-time setup: crates.io → crate Settings → Trusted Publishing.
- name: Authenticate to crates.io (OIDC)
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
id: crates-auth
- name: Publish to crates.io
# No --no-verify: re-build inside the publish step so crates.io ships
# exactly what the tests above ran against.
#
# Idempotent by design: crates.io publishes are immutable and permanent,
# so a resumed/re-run release (e.g. a transient npm or GitHub Release
# failure downstream) must not hard-fail here just because the version is
# already on the index. We detect that one specific, safe condition and
# continue so the remaining steps (npm publish, GitHub Release) can finish
# a partially-completed release. Any other publish error still fails fast.
run: |
set +e
out="$(cargo publish 2>&1)"; code=$?
echo "${out}"
if [ "${code}" -ne 0 ]; then
if echo "${out}" | grep -q "already exists on crates.io"; then
echo "::warning::metamorphic-crypto ${GITHUB_REF_NAME#v} is already on crates.io; skipping publish and resuming the release (npm + GitHub Release)."
else
exit "${code}"
fi
fi
env:
CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }}
# --- WASM build ---
- name: Install wasm-pack
# Build from the locked crates.io source (Cargo.lock verifies each
# dependency's checksum) instead of curl|tar of an unverified release
# tarball. Slower, but the supply chain is verifiable end to end.
run: |
cargo install wasm-pack --version "${WASM_PACK_VERSION}" --locked
wasm-pack --version
- name: Build WASM (--target web)
run: wasm-pack build --target web --release
# --- SBOM (CycloneDX) ---
# CISA/NIST SSDF/EO 14028 reference CycloneDX. Generated from Cargo.lock
# so it reflects the exact dependency tree the artifacts were built from.
- name: Install cargo-cyclonedx
run: cargo install cargo-cyclonedx --locked
- name: Generate SBOM
run: cargo cyclonedx --format json --override-filename sbom
- name: Stage SBOM alongside artifacts
run: cp sbom.json pkg/sbom.json
# --- Checksums ---
- name: Compute SHA-512 checksums
working-directory: pkg
run: sha512sum metamorphic_crypto.js metamorphic_crypto_bg.wasm sbom.json > SHA512SUMS
- name: Display checksums
run: cat pkg/SHA512SUMS
# --- Cosign (keyless signing via GitHub OIDC) ---
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign artifacts with cosign
run: |
cosign sign-blob --yes --bundle pkg/metamorphic_crypto.js.cosign.bundle pkg/metamorphic_crypto.js
cosign sign-blob --yes --bundle pkg/metamorphic_crypto_bg.wasm.cosign.bundle pkg/metamorphic_crypto_bg.wasm
# --- Attestation ---
- name: Attest WASM artifacts
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: |
pkg/metamorphic_crypto.js
pkg/metamorphic_crypto_bg.wasm
pkg/sbom.json
# --- npm publish ---
# npm trusted publishing (OIDC) requires npm >= 11.5.1. Node 24 already
# bundles a complete npm >= 11.5.1, so we use it directly instead of the
# in-place `npm install -g npm@latest` self-upgrade: that upgrade path is
# currently broken (it leaves npm unable to resolve its bundled `sigstore`
# module — "Cannot find module 'sigstore'" — during provenance
# generation). setup-node's bundled npm is a clean, complete install.
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Verify npm is new enough for trusted publishing
run: |
npm --version
node -e 'const [a,b,c]=process.argv[1].split(".").map(Number); const ok=a>11||(a===11&&(b>5||(b===5&&c>=1))); if(!ok){console.error("::error::npm "+process.argv[1]+" < 11.5.1; trusted publishing (OIDC) requires >= 11.5.1");process.exit(1)}' "$(npm --version)"
- name: Scope package for npm
working-directory: pkg
run: |
npm pkg set name="@f0rest8/metamorphic-crypto"
cp ../npm-README.md README.md
- name: Publish to npm
working-directory: pkg
# Trusted publishing (OIDC): no NODE_AUTH_TOKEN. npm authenticates via
# the workflow's OIDC identity and automatically attaches provenance
# (the verified badge on npmjs.com). id-token: write is already granted.
# One-time setup: npmjs.com → package Settings → Trusted Publisher.
#
# Idempotent for the same reason as the crates.io step above: a resumed
# release must not hard-fail if this version is already on the registry.
run: |
set +e
out="$(npm publish --access public 2>&1)"; code=$?
echo "${out}"
if [ "${code}" -ne 0 ]; then
if echo "${out}" | grep -qiE "cannot publish over|previously published|403"; then
echo "::warning::@f0rest8/metamorphic-crypto ${GITHUB_REF_NAME#v} is already on npm; skipping publish and resuming the release."
else
exit "${code}"
fi
fi
# --- GitHub Release ---
- name: Attach artifacts to GitHub Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: |
pkg/metamorphic_crypto.js
pkg/metamorphic_crypto_bg.wasm
pkg/sbom.json
pkg/SHA512SUMS
pkg/metamorphic_crypto.js.cosign.bundle
pkg/metamorphic_crypto_bg.wasm.cosign.bundle
fail_on_unmatched_files: true