name: Publish
on:
push:
tags: ['v[0-9]+.[0-9]+.[0-9]+*']
permissions:
contents: read
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
binary: ferrflow
archive: ferrflow-linux-x64.tar.gz
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
binary: ferrflow
archive: ferrflow-linux-arm64.tar.gz
- target: x86_64-apple-darwin
os: macos-latest
binary: ferrflow
archive: ferrflow-darwin-x64.tar.gz
- target: aarch64-apple-darwin
os: macos-latest
binary: ferrflow
archive: ferrflow-darwin-arm64.tar.gz
- target: x86_64-pc-windows-msvc
os: windows-latest
binary: ferrflow.exe
archive: ferrflow-windows-x64.zip
- target: aarch64-pc-windows-msvc
os: windows-latest
binary: ferrflow.exe
archive: ferrflow-windows-arm64.zip
- target: armv7-unknown-linux-musleabihf
os: ubuntu-latest
binary: ferrflow
archive: ferrflow-linux-armv7.tar.gz
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - name: Verify cargo toolchain
if: matrix.os != 'ubuntu-latest'
run: |
rustup show active-toolchain || rustup default stable
echo "${CARGO_HOME:-$HOME/.cargo}/bin" >> "$GITHUB_PATH"
which cargo
cargo --version
- name: Install cross
if: matrix.os == 'ubuntu-latest'
run: |
curl -fsSL --retry 6 --retry-delay 5 --retry-connrefused --retry-all-errors \
-o /tmp/cross.tar.gz \
https://github.com/cross-rs/cross/releases/latest/download/cross-x86_64-unknown-linux-musl.tar.gz
tar xz -C "$HOME/.cargo/bin" -f /tmp/cross.tar.gz
rm /tmp/cross.tar.gz
- name: Generate fixtures (PGO profiling workload, x64-linux only)
if: matrix.target == 'x86_64-unknown-linux-musl'
uses: FerrLabs/Fixtures@7b04ba1b93ababf2b557ca4521a243c23217ef0a with:
definitions: tests/fixtures/definitions
- name: Install musl tools (PGO x64-linux)
if: matrix.target == 'x86_64-unknown-linux-musl'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build x64-linux (PGO, if: matrix.target == 'x86_64-unknown-linux-musl'
shell: bash
run: |
if ! bash scripts/pgo-build.sh x86_64-unknown-linux-musl; then
echo "::warning::PGO build failed — falling back to LTO-only cross build for x64-linux"
n=0
until cross build --release --target x86_64-unknown-linux-musl; do
n=$((n + 1))
[ "$n" -ge 3 ] && { echo "fallback build failed after $n attempts" >&2; exit 1; }
echo "fallback build attempt $n failed; retrying in 15s..." >&2
sleep 15
done
fi
- name: Build (Linux)
if: matrix.os == 'ubuntu-latest' && matrix.target != 'x86_64-unknown-linux-musl'
shell: bash
run: |
n=0
until cross build --release --target ${{ matrix.target }}; do
n=$((n + 1))
[ "$n" -ge 3 ] && { echo "build failed after $n attempts" >&2; exit 1; }
echo "build attempt $n failed (likely a transient crate download); retrying in 15s..." >&2
sleep 15
done
- name: Build (macOS / Windows)
if: matrix.os != 'ubuntu-latest'
shell: bash
run: |
n=0
until cargo build --release --target ${{ matrix.target }}; do
n=$((n + 1))
[ "$n" -ge 3 ] && { echo "build failed after $n attempts" >&2; exit 1; }
echo "build attempt $n failed (likely a transient crate download); retrying in 15s..." >&2
sleep 15
done
- name: Package (Unix)
if: matrix.os != 'windows-latest'
run: |
tar -czf ${{ matrix.archive }} \
-C target/${{ matrix.target }}/release ${{ matrix.binary }}
- name: Package (Windows)
if: matrix.os == 'windows-latest'
run: |
Compress-Archive `
-Path target/${{ matrix.target }}/release/${{ matrix.binary }} `
-DestinationPath ${{ matrix.archive }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with:
name: ${{ matrix.archive }}
path: ${{ matrix.archive }}
upload:
name: Upload Release Assets
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
fetch-depth: 0
token: ${{ secrets.FERRFLOW_TOKEN }}
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 - name: Build ferrflow
run: cargo build --release
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
path: artifacts/
pattern: ferrflow-*
merge-multiple: true
- name: Generate shell completions
run: |
tar -xzf artifacts/ferrflow-linux-x64.tar.gz -C /tmp
chmod +x /tmp/ferrflow
mkdir -p completions
/tmp/ferrflow completions bash > completions/ferrflow.bash
/tmp/ferrflow completions zsh > completions/_ferrflow
/tmp/ferrflow completions fish > completions/ferrflow.fish
tar -czf artifacts/ferrflow-completions.tar.gz -C completions .
- name: Generate SHA256SUMS
run: |
set -euo pipefail
shopt -s nullglob
cd artifacts
: > SHA256SUMS
for f in ferrflow-*.tar.gz ferrflow-*.zip; do
[ -f "$f" ] || continue
sha256sum "$f" >> SHA256SUMS
done
if [ ! -s SHA256SUMS ]; then
echo "ERROR: no release archives found to checksum" >&2
exit 1
fi
cat SHA256SUMS
- name: Attest build provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 with:
subject-path: artifacts/*
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 with:
cosign-release: v3.1.3
- name: Sign release artifacts with cosign (keyless)
env:
COSIGN_EXPERIMENTAL: "1"
run: |
set -euo pipefail
shopt -s nullglob
# Retry: Sigstore hiccups ("stream error: INTERNAL_ERROR") killed
# this step twice (v5.2.2, v5.3.0) and orphaned their drafts — the
# known failure mode here is transient, so give it three shots.
sign_with_retry() {
local artifact="$1" attempt
for attempt in 1 2 3; do
if cosign sign-blob --yes \
--bundle "${artifact}.sigstore.json" \
"$artifact"; then
return 0
fi
echo "::warning::cosign signing failed for ${artifact} (attempt ${attempt}/3)"
sleep $((attempt * 10))
done
echo "cosign signing failed for ${artifact} after 3 attempts" >&2
return 1
}
for artifact in artifacts/ferrflow-*.tar.gz artifacts/ferrflow-*.zip artifacts/SHA256SUMS; do
[ -f "$artifact" ] || continue
echo "Signing $artifact"
sign_with_retry "$artifact"
done
- name: Install cyclonedx-cli for SBOM
run: cargo install --locked cargo-cyclonedx
- name: Generate SBOM (CycloneDX)
run: |
set -euo pipefail
cargo cyclonedx --format json --override-filename sbom
if [ ! -f sbom.json ]; then
echo "ERROR: cargo cyclonedx produced no root sbom.json" >&2
exit 1
fi
mv sbom.json artifacts/sbom.cdx.json
echo "SBOM size: $(wc -c < artifacts/sbom.cdx.json) bytes"
- name: Sign SBOM with cosign (keyless)
env:
COSIGN_EXPERIMENTAL: "1"
run: |
set -euo pipefail
for attempt in 1 2 3; do
if cosign sign-blob --yes \
--bundle artifacts/sbom.cdx.json.sigstore.json \
artifacts/sbom.cdx.json; then
exit 0
fi
echo "::warning::cosign SBOM signing failed (attempt ${attempt}/3)"
sleep $((attempt * 10))
done
echo "cosign SBOM signing failed after 3 attempts" >&2
exit 1
- name: Wait for draft release to be visible
run: |
TAG="${{ github.ref_name }}"
REPO="${{ github.repository }}"
for i in $(seq 1 60); do
ID=$(gh api "repos/$REPO/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"$TAG\") | .id" 2>/dev/null \
| head -n1)
if [ -n "$ID" ]; then
echo "Release $TAG visible after $((i*5))s (id=$ID)"
exit 0
fi
echo "Release $TAG not yet visible, retrying in 5s ($i/60)..."
sleep 5
done
echo "::warning::Release $TAG never appeared after 300s — creating it now as self-heal"
gh release create "$TAG" \
--draft \
--title "$TAG" \
--notes "Release notes will be filled by the post-publish step." \
--target "${{ github.sha }}"
echo "Created draft release $TAG via self-heal"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload assets to draft release
run: |
TAG="${{ github.ref_name }}"
for file in artifacts/*; do
gh release upload "$TAG" "$file" --clobber
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish draft release
run: ./target/release/ferrflow release
env:
FERRFLOW_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish any orphaned older draft releases
run: |
set -euo pipefail
TAG="${{ github.ref_name }}"
gh release list --limit 200 --json tagName,isDraft \
--jq '.[] | select(.isDraft) | .tagName' | while IFS= read -r draft; do
[ -n "$draft" ] || continue
[ "$draft" = "$TAG" ] && continue
case "$draft" in v[0-9]*) ;; *) continue ;; esac
newest=$(printf '%s\n%s\n' "$draft" "$TAG" | sort -V | tail -1)
[ "$newest" = "$TAG" ] || continue
echo "::warning::Publishing orphaned draft release $draft (left behind by a failed Publish run)"
gh release edit "$draft" --draft=false --latest=false
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-crate:
name: Publish crates.io
needs: upload
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 - run: cargo publish --allow-dirty --token ${{ secrets.CARGO_REGISTRY_TOKEN }}
publish-npm:
name: Publish npm
needs: upload
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Extract version
id: version
run: echo "value=${TAG#v}" >> $GITHUB_OUTPUT
env:
TAG: ${{ github.ref_name }}
- name: Publish platform packages and wrapper
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash npm/scripts/publish.sh "${{ steps.version.outputs.value }}"
publish-wasm:
name: Publish @ferrflow/wasm
needs: upload
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 with:
targets: wasm32-unknown-unknown
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Extract version
id: version
run: echo "value=${TAG#v}" >> $GITHUB_OUTPUT
env:
TAG: ${{ github.ref_name }}
- name: Build and publish
run: bash npm/scripts/publish-wasm.sh "${{ steps.version.outputs.value }}"
publish-docs:
name: Publish @ferrflow/doc
needs: upload
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Extract version
id: version
run: echo "value=${TAG#v}" >> $GITHUB_OUTPUT
env:
TAG: ${{ github.ref_name }}
- name: Check the manifest matches the tag
env:
VERSION: ${{ steps.version.outputs.value }}
run: |
declared=$(node -p "require('./docs/site/package.json').version")
if [ "$declared" != "$VERSION" ]; then
echo "::error::docs/site/package.json is $declared but the tag is $VERSION"
exit 1
fi
- name: Publish
working-directory: docs/site
env:
VERSION: ${{ steps.version.outputs.value }}
run: |
if npm view "@ferrflow/doc@$VERSION" version >/dev/null 2>&1; then
echo "@ferrflow/doc@$VERSION already on registry, skipping"
exit 0
fi
log="$RUNNER_TEMP/publish-doc.log"
if npm publish --access public >"$log" 2>&1; then
cat "$log"
exit 0
fi
cat "$log"
if grep -q 'E404' "$log" && ! npm view '@ferrflow/doc' version >/dev/null 2>&1; then
{
echo '### @ferrflow/doc has never been published'
echo
echo 'This job publishes over OIDC trusted publishing, which can add a version to a'
echo 'package it is registered for but cannot create the package name. npm answers the'
echo 'first push with a 404 rather than a permission error, which is what you see above.'
echo
echo 'One manual bootstrap fixes it for good:'
echo
echo '1. Publish once from a machine logged in to npm with rights on the `@ferrflow`'
echo ' scope: `cd docs/site && npm publish --access public`.'
echo '2. On npmjs.com, open the package settings and add this repository and the'
echo ' `Publish` workflow as a trusted publisher, the same way `@ferrflow/wasm` is'
echo ' set up.'
echo
echo 'Re-running this job then succeeds, and later releases need nothing.'
echo
echo 'Nothing else in the release is affected: the binary, the crate, the platform'
echo 'packages and the image all published normally, and the site keeps serving the'
echo 'documentation it already has.'
} >> "$GITHUB_STEP_SUMMARY"
echo "::error::@ferrflow/doc does not exist on npm yet. OIDC publishing cannot create a package name, so it needs one manual publish plus a trusted-publisher entry. See the job summary for the two steps."
fi
exit 1
publish-docker:
name: Publish Docker
needs: upload
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Download Linux artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with:
path: artifacts/
pattern: ferrflow-linux-*
merge-multiple: true
- name: Prepare binaries
run: |
mkdir -p docker-context
tar -xzf artifacts/ferrflow-linux-x64.tar.gz -C docker-context/
mv docker-context/ferrflow docker-context/ferrflow-amd64
tar -xzf artifacts/ferrflow-linux-arm64.tar.gz -C docker-context/
mv docker-context/ferrflow docker-context/ferrflow-arm64
chmod +x docker-context/ferrflow-*
cp Dockerfile.release docker-context/Dockerfile
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version
id: version
run: echo "value=${TAG#v}" >> $GITHUB_OUTPUT
env:
TAG: ${{ github.ref_name }}
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a id: docker-push
with:
context: docker-context
push: true
platforms: linux/amd64,linux/arm64
tags: |
ghcr.io/ferrlabs/ferrflow:latest
ghcr.io/ferrlabs/ferrflow:${{ steps.version.outputs.value }}
${{ secrets.DOCKERHUB_USERNAME }}/ferrflow:latest
${{ secrets.DOCKERHUB_USERNAME }}/ferrflow:${{ steps.version.outputs.value }}
- name: Attest Docker image
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 with:
subject-name: ghcr.io/ferrlabs/ferrflow
subject-digest: ${{ steps.docker-push.outputs.digest }}
push-to-registry: true
continue-on-error: true
- name: Install cosign for Docker signing
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 with:
cosign-release: v3.1.3
- name: Sign Docker image with cosign (keyless)
env:
COSIGN_EXPERIMENTAL: "1"
DIGEST: ${{ steps.docker-push.outputs.digest }}
VERSION: ${{ steps.version.outputs.value }}
run: |
set -euo pipefail
cosign sign --yes "ghcr.io/ferrlabs/ferrflow@${DIGEST}"
cosign sign --yes "ghcr.io/ferrlabs/ferrflow:${VERSION}"
cosign sign --yes "ghcr.io/ferrlabs/ferrflow:latest"