Files
wickra/.github/workflows/release.yml
T
kingchenc 5b23b36261 ci: pin CI dependency installs by hash (Scorecard PinnedDependencies) (#114)
* ci: use npm ci instead of npm install for reproducible installs

Pins the node binding dependency install to the committed package-lock.json
integrity hashes (OpenSSF Scorecard PinnedDependencies). npm ci installs
strictly from the lockfile; npm install could resolve newer patch versions.
Covers ci.yml and both release.yml node steps.

* ci: hash-pin Python dev tooling in ci.yml (Scorecard #19)

Replaces the unpinned 'pip install maturin pytest numpy hypothesis' with a
hash-locked '--require-hashes -r' install (OpenSSF Scorecard PinnedDependencies).

Two lock files are needed because numpy publishes no single release with wheels
for both cp39 and cp313 (<=2.0.2 has cp39 only, >=2.1 drops cp39):
  ci-dev-py39.txt  numpy 2.0.2  (Python 3.9, + tomli/exceptiongroup)
  ci-dev-py3.txt   numpy 2.4.6  (Python 3.10+)

The step selects the file by matrix.python-version under shell: bash. Both are
generated from ci-dev.in via uv (scripts/update-lockfiles.sh, added next).

* ci: hash-pin Python deps in bench.yml (Scorecard #16)

Replaces the unpinned 'pip install maturin numpy pandas talipp finta' with a
hash-locked '--require-hashes -r .github/requirements/bench.txt' install.
bench.yml runs on a single Python version (3.11), so one lock file (generated
from bench.in via uv) is sufficient.

* build: add scripts/update-lockfiles.sh to regenerate all lockfiles

One command refreshes every committed lockfile across languages: Cargo.lock and
fuzz/Cargo.lock (cargo update), the Node binding package-lock.json, and the
hash-pinned Python requirements under .github/requirements/ (uv pip compile
--generate-hashes). Uses uv for the Python locks so a target Python version's
hashed transitive closure can be resolved without that interpreter installed
(needed for the numpy cp39/cp313 split); bootstraps uv if absent.

.gitattributes pins *.sh to LF so the script stays runnable on Linux/macOS.

* ci: split ci-dev requirements per Python version + Dependabot rehash

Splits the single ci-dev.in into ci-dev-py39.in (numpy <2.1, the last series
with cp39 wheels) and ci-dev-py3.in (3.10+), giving a 1:1 .in->.txt layout.
The cap keeps Python 3.9 permanently installable and stops Dependabot from
proposing 3.9-breaking numpy bumps.

Adds a Dependabot pip entry on /.github/requirements so the hash-locked tooling
is kept current automatically; the canonical manual refresh stays
scripts/update-lockfiles.sh. Only the '# via -r' provenance lines in the .txt
change; no package versions or hashes move.

* ci: cache pip and npm downloads in the PR-loop jobs

Adds setup-python cache: pip (ci.yml python matrix + bench.yml, keyed on the
hash-locked requirements) and setup-node cache: npm (ci.yml node job, keyed on
bindings/node/package-lock.json), on both the primary and retry setup steps.

Scoped to jobs that actually install dependencies; the examples-smoke and
clippy-bindings jobs install nothing and are left uncached. release.yml is
intentionally left out: it runs only on tag push (not the PR loop) and is the
publish-critical path, so no caching is added there.

* docs: document hash-pinned requirements and update-lockfiles.sh

Updates the lockfile-policy table: the bindings/python row no longer claims CI
installs tooling unpinned, and a new .github/requirements row documents the
hash-locked CI/bench tooling and the per-Python-version ci-dev split. Adds a
paragraph pointing contributors at scripts/update-lockfiles.sh (uv-based,
self-bootstrapping) as the canonical lockfile refresh.

* docs: changelog entry for hash-pinned CI dependency installs
2026-06-01 17:58:31 +02:00

756 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
- 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
- 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
- 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
- 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
- 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
- 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:
fetch-depth: 0
- name: Resolve target tag
id: tag
run: |
if [ "${{ github.event_name }}" = "push" ] && [[ "${{ github.ref }}" == refs/tags/* ]]; then
tag="${{ github.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 }}"