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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 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/-.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@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6 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 .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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 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//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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 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@59012be0884e296ca2da49b530610e72c49039ad # v2.81.6 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 '; 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 = 'MIT OR Apache-2.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. # -------------------------------------------------------------------------- # -------------------------------------------------------------------------- # C ABI native libraries (bindings/c) — built per target on a native runner # (no cross toolchain needed) and attached to the GitHub Release as the # distribution channel. There is no package registry for the C ABI. # -------------------------------------------------------------------------- c-abi-build: name: C ABI library (${{ 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - 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: Build the C ABI library (cdylib + staticlib) run: cargo build -p wickra-c --release --target ${{ matrix.target }} - name: Package header + libraries shell: bash run: | set -e dir="wickra-c-${{ matrix.target }}" mkdir -p "$dir/include" "$dir/lib" cp bindings/c/include/wickra.h bindings/c/include/wickra.hpp "$dir/include/" for f in libwickra.so libwickra.a libwickra.dylib wickra.dll wickra.dll.lib wickra.lib; do src="target/${{ matrix.target }}/release/$f" [ -f "$src" ] && cp "$src" "$dir/lib/" done tar -czf "$dir.tar.gz" "$dir" echo "packaged $dir:"; ls -lR "$dir" - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: c-abi-${{ matrix.target }} path: wickra-c-${{ matrix.target }}.tar.gz if-no-files-found: error # Pack and publish the .NET binding to NuGet. Independent of the GitHub-release # job so a C# hiccup never blocks the C/C++ asset release. Authentication uses # NuGet Trusted Publishing (OIDC) — no long-lived API key. The 'wickra-release' # trusted-publishing policy on nuget.org (owner KingchenC, repo wickra-lib/wickra, # workflow release.yml) exchanges the GitHub OIDC token for a short-lived key. csharp-publish: name: Publish to NuGet needs: c-abi-build runs-on: ubuntu-latest environment: release permissions: contents: read id-token: write # request the GitHub OIDC token for trusted publishing steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Download the C ABI native libraries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: c-abi-* path: c-abi-artifacts - name: Stage native libraries into runtimes//native shell: bash run: | set -e declare -A RID=( [x86_64-unknown-linux-gnu]=linux-x64 [aarch64-unknown-linux-gnu]=linux-arm64 [x86_64-apple-darwin]=osx-x64 [aarch64-apple-darwin]=osx-arm64 [x86_64-pc-windows-msvc]=win-x64 [aarch64-pc-windows-msvc]=win-arm64 ) base=bindings/csharp/Wickra/runtimes for target in "${!RID[@]}"; do archive=$(find c-abi-artifacts -name "wickra-c-$target.tar.gz" | head -1) if [ -z "$archive" ]; then echo "::error::missing native artifact for $target"; exit 1 fi tmp=$(mktemp -d); tar -xzf "$archive" -C "$tmp" dest="$base/${RID[$target]}/native"; mkdir -p "$dest" for f in libwickra.so libwickra.dylib wickra.dll; do src=$(find "$tmp" -name "$f" | head -1) [ -n "$src" ] && cp "$src" "$dest/" done echo "staged ${RID[$target]}:"; ls -l "$dest" done - name: Pack shell: bash run: | version="${GITHUB_REF_NAME#v}" dotnet pack bindings/csharp/Wickra/Wickra.csproj -c Release -p:Version="$version" -o nupkg # Exchange the GitHub OIDC token for a short-lived (~1h) NuGet API key. # 'user' is the nuget.org profile name (the package owner), not an email. - name: NuGet login (OIDC -> temporary API key) uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0 id: nuget_login with: user: KingchenC # Pass the temporary key through the environment (not string-interpolated # into the script) so it cannot be parsed as shell — avoids template injection. - name: Push to NuGet shell: bash env: NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }} run: | dotnet nuget push "nupkg/*.nupkg" --api-key "$NUGET_API_KEY" \ --source https://api.nuget.org/v3/index.json --skip-duplicate - name: Upload the NuGet package as a build artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: nuget-package path: nupkg/*.nupkg if-no-files-found: error # Publish the Java binding to Maven Central. Independent of the GitHub-release # job so a Java hiccup never blocks the C/C++ asset release. The Maven Central # namespace (org.wickra) is verified; authentication uses the Sonatype Central # Portal token (CENTRAL_USERNAME / CENTRAL_PASSWORD) and artifacts are signed # with the GPG key (GPG_PRIVATE_KEY / GPG_PASSPHRASE). The pom version must # match the release tag (kept in sync by the version-bump checklist). java-publish: name: Publish to Maven Central needs: c-abi-build runs-on: ubuntu-latest environment: release steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Download the C ABI native libraries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: c-abi-* path: c-abi-artifacts - name: Stage native libraries into resources/native/- shell: bash run: | set -e declare -A PLAT=( [x86_64-unknown-linux-gnu]=linux-x64 [aarch64-unknown-linux-gnu]=linux-arm64 [x86_64-apple-darwin]=osx-x64 [aarch64-apple-darwin]=osx-arm64 [x86_64-pc-windows-msvc]=win-x64 [aarch64-pc-windows-msvc]=win-arm64 ) base=bindings/java/src/main/resources/native for target in "${!PLAT[@]}"; do archive=$(find c-abi-artifacts -name "wickra-c-$target.tar.gz" | head -1) if [ -z "$archive" ]; then echo "::error::missing native artifact for $target"; exit 1 fi tmp=$(mktemp -d); tar -xzf "$archive" -C "$tmp" dest="$base/${PLAT[$target]}"; mkdir -p "$dest" for f in libwickra.so libwickra.dylib wickra.dll; do src=$(find "$tmp" -name "$f" | head -1) [ -n "$src" ] && cp "$src" "$dest/" done echo "staged ${PLAT[$target]}:"; ls -l "$dest" done # setup-java writes a settings.xml with the 'central' server credentials # (mapped from the env vars below) and imports the GPG signing key. - name: Set up JDK 22 + Maven Central credentials + GPG uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 with: distribution: temurin java-version: "22" server-id: central server-username: CENTRAL_USERNAME server-password: CENTRAL_PASSWORD gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} gpg-passphrase: GPG_PASSPHRASE - name: Deploy to Maven Central env: CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: mvn -B -f bindings/java -Prelease deploy -DskipTests # Mirror the in-repo Go module (bindings/go) to the standalone wickra-go # repository so `go get github.com/wickra-lib/wickra-go` builds with no extra # steps. The in-repo module keeps the prebuilt libraries git-ignored; the # mirror commits them per platform under lib/_/ alongside the # vendored header. Independent of the GitHub-release job so a Go hiccup never # blocks the C/C++ asset release. The push uses a fine-grained PAT # (WICKRA_GO_MIRROR_TOKEN, contents:write on wickra-go); the mirror is a # derived artifact, so its bot commit is intentionally unsigned. go-mirror: name: Mirror the Go module to wickra-go needs: c-abi-build runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - name: Download the C ABI native libraries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: c-abi-* path: c-abi-artifacts - name: Assemble the wickra-go module tree shell: bash run: | set -e out=wickra-go-module mkdir -p "$out/include" "$out/lib" # Single-package Go source + vendored C ABI header. cp bindings/go/*.go "$out/" cp bindings/go/include/wickra.h "$out/include/" cp bindings/go/README.md "$out/" # Ship the dual license so pkg.go.dev detects a redistributable license. cp LICENSE-MIT LICENSE-APACHE "$out/" # Standalone module path (the in-repo go.mod points at the subdir). printf 'module github.com/wickra-lib/wickra-go\n\ngo 1.23\n' > "$out/go.mod" # Point install instructions at the standalone module path. sed -i 's#github.com/wickra-lib/wickra/bindings/go#github.com/wickra-lib/wickra-go#g' \ "$out/README.md" "$out"/*.go # Stage each prebuilt library under lib/_/ (committed in # the mirror, unlike the in-repo lib/.gitignore). " ". declare -A GO=( [x86_64-unknown-linux-gnu]="linux_amd64 libwickra.so" [aarch64-unknown-linux-gnu]="linux_arm64 libwickra.so" [x86_64-apple-darwin]="darwin_amd64 libwickra.dylib" [aarch64-apple-darwin]="darwin_arm64 libwickra.dylib" [x86_64-pc-windows-msvc]="windows_amd64 wickra.dll" [aarch64-pc-windows-msvc]="windows_arm64 wickra.dll" ) for target in "${!GO[@]}"; do read -r dir libfile <<< "${GO[$target]}" archive=$(find c-abi-artifacts -name "wickra-c-$target.tar.gz" | head -1) if [ -z "$archive" ]; then echo "::error::missing native artifact for $target"; exit 1 fi tmp=$(mktemp -d); tar -xzf "$archive" -C "$tmp" src=$(find "$tmp" -name "$libfile" | head -1) if [ -z "$src" ]; then echo "::error::missing $libfile for $target"; exit 1 fi mkdir -p "$out/lib/$dir"; cp "$src" "$out/lib/$dir/" done echo "assembled module:"; find "$out" -type f | sort - name: Push to wickra-go and tag the release shell: bash env: MIRROR_TOKEN: ${{ secrets.WICKRA_GO_MIRROR_TOKEN }} run: | set -e version="${GITHUB_REF_NAME#v}" remote="https://x-access-token:${MIRROR_TOKEN}@github.com/wickra-lib/wickra-go.git" git clone -q "$remote" mirror-repo cd mirror-repo git config user.name "wickra-bot" git config user.email "support@wickra.org" git symbolic-ref HEAD refs/heads/main # Replace all tracked content with the freshly assembled tree. find . -mindepth 1 -maxdepth 1 -not -name .git -exec rm -rf {} + cp -r ../wickra-go-module/. . git add -A if git diff --cached --quiet; then echo "wickra-go already up to date; tagging only" else git -c commit.gpgsign=false commit -q -m "Release v$version" fi git push origin HEAD:refs/heads/main git tag "v$version" git push origin "v$version" github-release: name: Attach assets to the draft GitHub Release needs: [cargo-publish, python-publish, node-publish, wasm-publish, c-abi-build] 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 }} version: ${{ steps.tag.outputs.version }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 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 "version=${tag#v}" >> "$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 for 10 languages: native Rust, Python, Node.js, WASM plus a C ABI hub for C, C++, C#, Go, Java, R. ### Install ```bash cargo add wickra pip install wickra npm install wickra npm install wickra-wasm dotnet add package Wickra go get github.com/wickra-lib/wickra-go ``` Java (Gradle, Maven Central): ```gradle implementation("org.wickra:wickra:${{ steps.tag.outputs.version }}") ``` R (r-universe): ```r install.packages("wickra", repos = "https://wickra-lib.r-universe.dev") ``` ### Attached assets Pre-built artefacts for every supported platform — the same files this workflow run published to crates.io, PyPI, and npm. C# ships to NuGet, Java to Maven Central, Go to the wickra-go module, and R to r-universe via their own release jobs. - `*.whl` / `wickra-*.tar.gz` — Python wheels + sdist (5 platforms, ABI3 ≥ 3.9) - `wickra.*.node` — native Node.js 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) - `wickra-c-.tar.gz` — C ABI: `include/wickra.h` + `wickra.hpp` and the cdylib/staticlib per target (linux/macos/windows × x64/arm64), the hub for C, C++, C#, Go, Java and R ### 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 }}"