Files
wickra/.github/workflows/release.yml
T
kingchenc 73415cd2dc ci: zizmor security hardening (#133)
* ci: pass ref context through env in release tag step

zizmor flagged the "Resolve target tag" step in release.yml for
template-injection: github.event_name / github.ref / github.ref_name
were interpolated directly into the shell script. On a tag push the tag
name is attacker-influenceable, so a crafted tag could inject commands.

Move all three context values into the step env and reference them as
shell variables instead. Verified with zizmor 1.16.3: template-injection
findings on release.yml drop from 2 to 0.

* ci: accept release.yml build caches via zizmor config

The release pipeline restores Swatinem/rust-cache and actions/setup-node
caches as a deliberate optimisation. zizmor flags all eight under
cache-poisoning because release.yml publishes to crates.io / PyPI / npm.
The caches are maintainer-controlled and the restore speedup is kept on
purpose, so accept the finding via a zizmor config ignore for release.yml
rather than running cache-free release builds. (Six of the eight are
actions/setup-node, reported at Low confidence.)

Adds .github/zizmor.yml; release.yml now reports 0 high findings.

* ci: drop persisted checkout credentials on read-only jobs

zizmor's artipacked audit flags every actions/checkout that keeps the
default persisted credential: the token is written to the runner's
.git/config, where it can leak if a later step packs .git into an
uploaded artifact, or be read by another step in the same job.

Set persist-credentials: false on the 20 checkouts whose jobs never push
or authenticate to git (build/test/clippy/msrv/coverage/supply-chain/
fuzz/python/wasm/node in ci.yml, plus bench.yml, codeql.yml, the seven
release.yml build/publish jobs, and sync-metadata.yml). The publish and
release jobs authenticate to crates.io / npm / PyPI / the GitHub API with
their own tokens, not persisted git credentials, so this is safe.

sync-about.yml genuinely pushes the indicator-count fix-up to the PR
branch, so it keeps its credential and is accepted via .github/zizmor.yml.
zizmor artipacked for the repo drops to 0 (0 high, 0 medium remaining).
2026-06-02 02:08:40 +02:00

777 lines
33 KiB
YAML

name: Release
on:
push:
tags: ["v*"]
workflow_dispatch:
# Least-privilege default for the auto-injected GITHUB_TOKEN. The publish jobs
# (cargo/python/node) push to external registries via their own secrets
# (CARGO_REGISTRY_TOKEN / PYPI_API_TOKEN / NPM_TOKEN), not the GITHUB_TOKEN, so
# they need no repo write. The jobs that genuinely write through the
# GITHUB_TOKEN — github-release (contents: write), node-/wasm-publish and
# attestations (id-token / attestations: write) — declare those rights in their
# own job-level permissions blocks, which override this default (OpenSSF
# Scorecard: Token-Permissions).
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Network-flake resilience: retry transient registry/DNS failures at the tool
# level so a blip fetching crates.io / npm inside any build or publish step
# (cargo, napi, maturin, wasm-pack, npm) retries automatically instead of
# failing the job. Cargo treats "couldn't resolve host" / connect / timeout as
# spurious and retries with backoff; 10 attempts ride out a transient DNS blip.
CARGO_NET_RETRY: "10"
CARGO_NET_GIT_FETCH_WITH_CLI: "true"
npm_config_fetch_retries: "5"
npm_config_fetch_retry_maxtimeout: "120000"
PIP_RETRIES: "5"
PIP_DEFAULT_TIMEOUT: "120"
jobs:
# --------------------------------------------------------------------------
# crates.io: three crates in dependency order
# --------------------------------------------------------------------------
cargo-publish:
name: Publish to crates.io
runs-on: ubuntu-latest
# The publish jobs run with long-lived registry tokens. Binding them to a
# protected GitHub environment lets the org require a reviewer to approve
# each release and restrict which tags/branches may deploy, so the secrets
# are not reachable from an arbitrary workflow run. The `release`
# environment and its protection rules are configured under repo
# Settings -> Environments.
environment: release
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
# Idempotent publishing: if the version is already on crates.io we
# treat that as success so re-runs of the workflow don't fail.
- name: Publish wickra-core (idempotent)
run: |
out=$(cargo publish -p wickra-core --token "$CARGO_REGISTRY_TOKEN" 2>&1) && echo "$out" \
|| (echo "$out" | grep -q "already uploaded\|already exists" && echo "skip: already published" \
|| (echo "$out"; exit 1))
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- name: Wait for crates.io index to refresh
run: sleep 45
- name: Publish wickra-data (idempotent)
run: |
out=$(cargo publish -p wickra-data --token "$CARGO_REGISTRY_TOKEN" 2>&1) && echo "$out" \
|| (echo "$out" | grep -q "already uploaded\|already exists" && echo "skip: already published" \
|| (echo "$out"; exit 1))
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
- name: Wait for crates.io index to refresh
run: sleep 45
- name: Publish wickra (idempotent)
run: |
out=$(cargo publish -p wickra --token "$CARGO_REGISTRY_TOKEN" 2>&1) && echo "$out" \
|| (echo "$out" | grep -q "already uploaded\|already exists" && echo "skip: already published" \
|| (echo "$out"; exit 1))
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# Produce .crate files for the GitHub Release attachments. `cargo package`
# writes them to target/package/<name>-<version>.crate.
#
# No --allow-dirty: `actions/checkout` gives a clean tree and nothing
# above mutates it. No --no-verify: every crate was just published to
# crates.io in the steps above, so the verification build resolves its
# workspace dependencies from the registry and confirms each .crate
# actually builds before it is attached to the release.
- name: Build .crate files for release attachment
run: |
cargo package -p wickra-core
cargo package -p wickra-data
cargo package -p wickra
- name: Upload .crate files
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: crate-files
path: target/package/*.crate
# CycloneDX SBOM per published crate. Attached to the GitHub Release
# alongside the .crate / .whl / .tgz artefacts so downstream
# consumers can audit the published dependency tree without
# re-resolving Cargo.lock.
- name: Install cargo-cyclonedx
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: cargo-cyclonedx
- name: Generate CycloneDX SBOMs
run: |
# cargo-cyclonedx walks the whole workspace in a single pass and
# writes a <package>.cdx.json next to each member's Cargo.toml; it
# has no -p/--package selector. Collect the three crates.io crates
# (the .crate files published by this job) into the upload dir.
cargo cyclonedx --format json --top-level
mkdir -p sboms
cp crates/wickra-core/wickra-core.cdx.json sboms/
cp crates/wickra-data/wickra-data.cdx.json sboms/
cp crates/wickra/wickra.cdx.json sboms/
ls -lh sboms/
- name: Upload SBOMs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sboms
path: sboms/*.cdx.json
# --------------------------------------------------------------------------
# PyPI: cross-platform wheels + sdist
# --------------------------------------------------------------------------
python-wheels:
name: Build wheels (${{ matrix.target }}/${{ matrix.manylinux }} on ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
# glibc Linux (manylinux)
- { os: ubuntu-latest, target: x86_64, manylinux: auto }
- { os: ubuntu-latest, target: aarch64, manylinux: auto }
# musl Linux (Alpine and other musl distros)
- { os: ubuntu-latest, target: x86_64, manylinux: musllinux_1_2 }
- { os: ubuntu-latest, target: aarch64, manylinux: musllinux_1_2 }
# macOS
- { os: macos-latest, target: x86_64, manylinux: auto }
- { os: macos-latest, target: aarch64, manylinux: auto }
# Windows
- { os: windows-latest, target: x64, manylinux: auto }
- { os: windows-11-arm, target: aarch64, manylinux: auto }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
id: setup_python
continue-on-error: true
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Wait before Python retry
if: steps.setup_python.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-python failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Python (retry)
if: steps.setup_python.outcome == 'failure'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Sync root README into bindings/python so it ships with the wheel
shell: bash
run: cp README.md bindings/python/README.md
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
working-directory: bindings/python
target: ${{ matrix.target }}
args: --release --strip --out dist
manylinux: ${{ matrix.manylinux }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# Include manylinux in the name so the glibc and musl x86_64/aarch64
# builds do not collide on the same artifact name.
name: wheels-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux }}
path: bindings/python/dist/*
python-sdist:
name: Build Python sdist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Sync root README into bindings/python so it ships in the sdist
run: cp README.md bindings/python/README.md
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
working-directory: bindings/python
command: sdist
args: --out dist
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wheels-sdist
path: bindings/python/dist/*
python-publish:
name: Publish to PyPI
needs: [python-wheels, python-sdist]
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
pattern: wheels-*
merge-multiple: true
- name: Upload to PyPI
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
command: upload
args: --skip-existing dist/*
env:
MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
# --------------------------------------------------------------------------
# npm: per-platform native binaries + main meta-package
# --------------------------------------------------------------------------
node-build:
name: Node build (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- { host: ubuntu-latest, target: x86_64-unknown-linux-gnu }
- { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
- { host: macos-latest, target: x86_64-apple-darwin }
- { host: macos-latest, target: aarch64-apple-darwin }
- { host: windows-latest, target: x86_64-pc-windows-msvc }
- { host: windows-11-arm, target: aarch64-pc-windows-msvc }
runs-on: ${{ matrix.host }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
timeout-minutes: 6
- name: Install Node deps
working-directory: bindings/node
run: npm ci
- name: Build native module
working-directory: bindings/node
run: npx napi build --platform --release --target ${{ matrix.target }}
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bindings-${{ matrix.target }}
path: bindings/node/wickra.*.node
if-no-files-found: error
node-publish:
name: Publish to npm
needs: node-build
runs-on: ubuntu-latest
environment: release
# `id-token: write` lets npm publish embed a Sigstore provenance
# attestation generated from the GitHub Actions OIDC token. The npm
# registry then shows a "Verified provenance" badge and lets
# consumers verify the package was built from this exact workflow
# run.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install Node deps
working-directory: bindings/node
run: npm ci
- name: Download all platform binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: bindings/node/artifacts
pattern: bindings-*
# The npm/<platform>/package.json templates are committed to the
# repo (see bindings/node/npm/), so `napi artifacts` only needs to
# drop the freshly built .node binary into the matching directory.
- name: Move binaries into platform package layout
working-directory: bindings/node
run: npx napi artifacts --dir artifacts
# Publish each platform package individually. Skip versions that are
# already on npm. A first-attempt 403 from npm's spam filter is
# tolerated for a single 30-second retry — that historically clears
# rate-limit-driven false positives. Anything that still fails after
# the retry is a *real* failure (the platform binary will be missing
# from `optionalDependencies` and Windows-style installs will break,
# exactly the regression that produced audit finding R20) — fail the
# job loudly so the release does not silently land in a half-published
# state. Previously this loop swallowed the second-attempt failure with
# a `::warning::` and `return 0`; that mask is removed.
- name: Publish platform packages (idempotent)
working-directory: bindings/node
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set +e
version=$(node -p "require('./package.json').version")
fail=0
publish_dir() {
local dir=$1
local pkg=$(basename "$dir")
local pkgname="wickra-$pkg"
local existing
existing=$(npm view "$pkgname@$version" version 2>/dev/null)
if [ "$existing" = "$version" ]; then
echo "::notice::skip $pkgname@$version (already on npm)"
return 0
fi
echo "::group::publish $pkgname@$version"
# --ignore-scripts: a per-platform package must never run lifecycle
# scripts during publish (npm runs prepublishOnly/prepare/etc. from
# the package being published — a malicious or stray script would
# execute with the npm token in the environment).
(cd "$dir" && npm publish --access public --ignore-scripts --provenance)
local rc=$?
echo "::endgroup::"
if [ "$rc" -ne 0 ]; then
echo "::warning::first attempt of $pkgname failed (rc=$rc); retrying after 30s"
sleep 30
(cd "$dir" && npm publish --access public --ignore-scripts --provenance)
rc=$?
fi
if [ "$rc" -ne 0 ]; then
echo "::error::$pkgname could not be published — the release would land with a missing platform binary; failing the job."
return 1
fi
return 0
}
for dir in npm/*/; do
publish_dir "$dir" || fail=1
done
exit $fail
- name: Sync root README into bindings/node so it ships with the npm tarball
# npm reads README.md from the package directory at publish time. Copy
# the canonical root README in just before the publish so every
# registry shows the same project page.
shell: bash
run: cp ../../README.md README.md
working-directory: bindings/node
- name: Publish main package to npm (idempotent)
working-directory: bindings/node
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
shell: bash
run: |
# GitHub Actions defaults to `bash -e`. `npm view` on a not-yet-
# published package returns 1, which would kill the step before
# we got to publish. Force a graceful empty result instead.
set +e
version=$(node -p "require('./package.json').version")
existing=$(npm view "wickra@$version" version 2>/dev/null)
if [ "$existing" = "$version" ]; then
echo "::notice::skip wickra@$version (already on npm)"
exit 0
fi
# --ignore-scripts so any leftover prepublish hooks (which would
# otherwise try to republish the already-published platform
# subpackages) can't sabotage the main publish.
npm publish --access public --ignore-scripts --provenance
rc=$?
if [ "$rc" -ne 0 ]; then
echo "::warning::first attempt failed (rc=$rc); retrying after 30s"
sleep 30
npm publish --access public --ignore-scripts --provenance
rc=$?
fi
exit $rc
- name: Pack node tarballs for release attachment
working-directory: bindings/node
run: |
# Main package
npm pack --ignore-scripts
# Each per-platform package (the binaries were already moved in by
# napi artifacts). --ignore-scripts for the same reason as publish:
# packing must not execute lifecycle scripts from the packed dir.
for d in npm/*/; do
(cd "$d" && npm pack --ignore-scripts)
done
- name: Upload Node tarballs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: node-tarballs
path: |
bindings/node/*.tgz
bindings/node/npm/*/*.tgz
# --------------------------------------------------------------------------
# WASM: wasm-pack build + npm publish (as `wickra-wasm`)
# --------------------------------------------------------------------------
# Note: this job's npm publish call uses `--provenance` (see below),
# which requires the `id-token: write` permission set at the job level.
wasm-publish:
name: Publish wickra-wasm to npm
runs-on: ubuntu-latest
environment: release
# `id-token: write` lets npm publish embed a Sigstore provenance
# attestation generated from the GitHub Actions OIDC token (same
# mechanism as the node-publish job above).
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
id: setup_node
continue-on-error: true
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Wait before Node retry
if: steps.setup_node.outcome == 'failure'
shell: bash
run: |
echo "::warning::setup-node failed (likely CDN flake), waiting 30s before retry..."
sleep 30
- name: Set up Node (retry)
if: steps.setup_node.outcome == 'failure'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
with:
targets: wasm32-unknown-unknown
- name: Install wasm-pack (latest, via prebuilt binary)
# See the matching note in ci.yml: jetli's default installs an old
# 0.10.x wasm-pack whose build subcommand rejects --features.
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
with:
tool: wasm-pack
- name: Sync root README into bindings/wasm so wasm-pack ships it in pkg/
# wasm-pack copies the crate's README.md into the generated pkg/
# directory it then publishes. Refresh it from the canonical root
# README right before the build.
run: cp README.md bindings/wasm/README.md
- name: Build WASM package (bundler target)
run: wasm-pack build bindings/wasm --target bundler --release --features panic-hook
- name: Enrich generated package.json with author + repo metadata
working-directory: bindings/wasm/pkg
run: |
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json'));
pkg.author = 'kingchenc <support@wickra.org>';
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
pkg.homepage = 'https://github.com/wickra-lib/wickra';
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
pkg.license = 'PolyForm-Noncommercial-1.0.0';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
"
- name: Pack wickra-wasm for release attachment
working-directory: bindings/wasm/pkg
run: npm pack
- name: Upload WASM tarball
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wasm-tarball
path: bindings/wasm/pkg/wickra-wasm-*.tgz
- name: Publish wickra-wasm to npm (idempotent)
working-directory: bindings/wasm/pkg
run: |
out=$(npm publish --access public --provenance 2>&1) && echo "$out" \
|| (echo "$out" | grep -q "You cannot publish over" && echo "skip: version already on npm" \
|| (echo "$out"; exit 1))
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# --------------------------------------------------------------------------
# GitHub Release: attach every built artefact to the tag's release page.
#
# The release is created as a DRAFT here and only flipped to published by the
# downstream publish-release job, after the provenance bundle is attached. That
# ordering (draft -> attach everything -> publish) makes the pipeline compatible
# with GitHub release immutability, which locks assets at publish time (P24):
# the old "publish, then upload provenance" order would have the provenance
# upload rejected once immutability is enabled.
# --------------------------------------------------------------------------
github-release:
name: Attach assets to the draft GitHub Release
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
runs-on: ubuntu-latest
permissions:
contents: write
# Expose the resolved tag so the attestations job can attach the provenance
# bundle to this same release without re-resolving it.
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Resolve target tag
id: tag
# Pass the (potentially attacker-influenceable on a tag push) ref context
# through the environment instead of interpolating it into the shell
# script, so a crafted tag name cannot inject commands (zizmor:
# template-injection).
env:
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }}
run: |
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/* ]]; then
tag="$REF_NAME"
else
# workflow_dispatch / non-tag push: attach to the latest v* tag.
tag=$(git tag --list 'v*' --sort=-v:refname | head -n1)
fi
if [ -z "$tag" ]; then
echo "::error::no v* tag found to attach assets to"
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "::notice::attaching assets to release $tag"
- name: Download all build artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: artifacts
- name: Stage release assets
run: |
set -e
mkdir -p release-assets
# Python wheels + sdist (5 wheel artifacts + 1 sdist artifact).
find artifacts -type f -name "*.whl" -exec cp {} release-assets/ \;
find artifacts -type f -name "*.tar.gz" -exec cp {} release-assets/ \;
# Native Node binaries (one per platform).
find artifacts -type f -name "*.node" -exec cp {} release-assets/ \;
# Node npm-pack tarballs (main + per-platform).
find artifacts -type f -name "wickra-*.tgz" -exec cp {} release-assets/ \;
# Cargo .crate files (one per workspace member).
find artifacts -type f -name "*.crate" -exec cp {} release-assets/ \;
# CycloneDX SBOMs (one per published crate).
find artifacts -type f -name "*.cdx.json" -exec cp {} release-assets/ \;
ls -lh release-assets/
echo "asset-count=$(ls release-assets/ | wc -l)"
- name: Create / update the draft GitHub Release with assets
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: Wickra ${{ steps.tag.outputs.tag }}
files: release-assets/*
generate_release_notes: true
fail_on_unmatched_files: false
# Created as a draft; publish-release flips it to published + latest once
# the provenance bundle is attached (P24, immutability-ready).
draft: true
body: |
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
### Install
```bash
cargo add wickra
pip install wickra
npm install wickra
npm install wickra-wasm
```
### Attached assets
Pre-built artefacts for every supported platform — the same files that
were uploaded to crates.io, PyPI, and npm by this workflow run.
- `*.whl` / `wickra-*.tar.gz` — Python wheels + sdist (5 platforms, ABI3 ≥ 3.9)
- `wickra.*.node` — native Node bindings (linux-x64-gnu, darwin-x64,
darwin-arm64, win32-x64-msvc)
- `wickra-*.tgz` — npm-pack tarballs (main package + per-platform subpackages + WASM)
- `*.crate` — cargo source crates (wickra-core, wickra-data, wickra)
### Auto-generated changelog
See below; GitHub computes it from the commits since the previous tag.
# --------------------------------------------------------------------------
# Build provenance attestations (findings P13.2)
# --------------------------------------------------------------------------
attestations:
name: Attest build provenance
needs: [cargo-publish, python-wheels, python-sdist, github-release]
runs-on: ubuntu-latest
# Signed SLSA build-provenance attestations for the published crates and
# Python wheels/sdist. npm tarballs already carry inline Sigstore provenance
# from `npm publish --provenance`, so they are covered there.
#
# The job stays isolated from the *publishes*: cargo/PyPI/npm all run upstream
# of github-release, so a Sigstore hiccup here can never block or corrupt a
# publish (the isolation the SBOM step lacked before #79). It additionally
# `needs: github-release` so the (still-draft) GitHub Release already exists
# when it attaches the provenance bundle as a release asset (P21.1e) — OpenSSF
# Scorecard's Signed-Releases check scans release *assets* (*.intoto.jsonl),
# not GitHub's separate attestations store, so the bundle has to live on the
# release. The release is published afterwards by the publish-release job
# whether or not this attestation succeeds (P24), so a failure here still only
# costs the provenance asset, never the release.
permissions:
id-token: write # OIDC for keyless Sigstore signing
attestations: write # write the attestations to this repo
contents: write # upload the provenance bundle as a release asset
steps:
- name: Download crate files
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: crate-files
path: artifacts/crates
- name: Download wheels + sdist
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: wheels-*
path: artifacts/python
merge-multiple: true
- name: Attest build provenance
id: attest
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: |
artifacts/crates/*.crate
artifacts/python/*.whl
artifacts/python/*.tar.gz
# Attach the Sigstore provenance bundle to the GitHub Release as a
# `*.intoto.jsonl` asset so OpenSSF Scorecard's Signed-Releases check finds
# signed provenance on the release itself (P21.1e). attest-build-provenance
# writes a single JSONL bundle covering every subject above; copy it to a
# `.intoto.jsonl`-suffixed name and upload with --clobber so re-runs are
# idempotent. github.token has contents: write here, which is all gh needs.
- name: Attach provenance bundle to the GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.github-release.outputs.tag }}
BUNDLE: ${{ steps.attest.outputs.bundle-path }}
run: |
if [ -z "$TAG" ]; then
echo "::error::no tag resolved from github-release; cannot attach provenance."
exit 1
fi
if [ -z "$BUNDLE" ] || [ ! -f "$BUNDLE" ]; then
echo "::error::attestation bundle not found at '$BUNDLE'."
exit 1
fi
dest="wickra-${TAG}.provenance.intoto.jsonl"
cp "$BUNDLE" "$dest"
echo "Uploading $dest to release $TAG"
gh release upload "$TAG" "$dest" --clobber --repo "${{ github.repository }}"
# --------------------------------------------------------------------------
# Publish the drafted release LAST (P24 — immutability-ready).
#
# github-release creates the release as a draft and attestations attaches the
# provenance bundle to it; only now, with every asset in place, is it flipped to
# published + latest. With GitHub release immutability enabled, assets lock at
# this publish step — so the provenance bundle and every build artefact are
# already present and never need a (rejected) post-publish upload.
#
# `if: always() && needs.github-release.result == 'success'` preserves the old
# robustness: the release is published whenever the draft was created, even if
# the attestations job hit a Sigstore hiccup — that only costs the provenance
# asset, exactly as before. If github-release was skipped (a publish job failed)
# there is no draft, so this is skipped too and no release is published.
# --------------------------------------------------------------------------
publish-release:
name: Publish the GitHub Release
needs: [github-release, attestations]
if: always() && needs.github-release.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: write # flip the draft release to published
steps:
- name: Flip the draft release to published (latest)
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.github-release.outputs.tag }}
run: |
if [ -z "$TAG" ]; then
echo "::error::no tag resolved from github-release; cannot publish."
exit 1
fi
echo "::notice::publishing release $TAG (draft -> published, latest)"
gh release edit "$TAG" --draft=false --latest=true --repo "${{ github.repository }}"