diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 10155f9..36e0100 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,23 +1,18 @@ -name: Build containers +name: Build on: - push: - branches: [main] - tags: ["v*"] - pull_request: - branches: [main] - -permissions: - contents: read - packages: write - -concurrency: - group: build-containers-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + workflow_call: + inputs: + push: + required: true + type: boolean + source_sha: + required: true + type: string jobs: build: - if: github.ref_type != 'tag' + name: ${{ matrix.name }} (${{ matrix.platform }}) runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -25,7 +20,6 @@ jobs: include: - name: cpu file: Dockerfile - suffix: -cpu staging_suffix: -cpu-amd64 cache_scope: cpu-amd64 build_args: "" @@ -33,7 +27,6 @@ jobs: runner: ubuntu-latest - name: cpu file: Dockerfile - suffix: -cpu staging_suffix: -cpu-arm64 cache_scope: cpu-arm64 build_args: "" @@ -41,7 +34,6 @@ jobs: runner: ubuntu-24.04-arm - name: turing file: Dockerfile.cuda - suffix: -turing staging_suffix: -turing-amd64 cache_scope: cuda-turing build_args: CUDA_COMPUTE_CAPS=75 @@ -49,7 +41,6 @@ jobs: runner: ubuntu-latest - name: ampere file: Dockerfile.cuda - suffix: -ampere staging_suffix: -ampere-amd64 cache_scope: cuda-ampere build_args: CUDA_COMPUTE_CAPS=80;86 @@ -57,7 +48,6 @@ jobs: runner: ubuntu-latest - name: ada-lovelace file: Dockerfile.cuda - suffix: -ada-lovelace staging_suffix: -ada-lovelace-amd64 cache_scope: cuda-ada-lovelace build_args: CUDA_COMPUTE_CAPS=89 @@ -65,7 +55,6 @@ jobs: runner: ubuntu-latest - name: hopper file: Dockerfile.cuda - suffix: -hopper staging_suffix: -hopper-amd64 cache_scope: cuda-hopper build_args: CUDA_COMPUTE_CAPS=90 @@ -73,7 +62,6 @@ jobs: runner: ubuntu-latest - name: blackwell file: Dockerfile.cuda - suffix: -blackwell staging_suffix: -blackwell-amd64 cache_scope: cuda-blackwell-amd64 build_args: CUDA_COMPUTE_CAPS=100;120 @@ -81,7 +69,6 @@ jobs: runner: ubuntu-latest - name: blackwell file: Dockerfile.cuda - suffix: -blackwell staging_suffix: -blackwell-arm64 cache_scope: cuda-blackwell-arm64 build_args: CUDA_COMPUTE_CAPS=121 @@ -89,19 +76,9 @@ jobs: runner: ubuntu-24.04-arm steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Determine cache permissions - id: cache - env: - TRUSTED_CACHE_WRITER: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - shell: bash - run: | - if [[ "${TRUSTED_CACHE_WRITER}" == "true" ]]; then - echo "write=true" >> "${GITHUB_OUTPUT}" - echo "sccache=on" >> "${GITHUB_OUTPUT}" - else - echo "write=false" >> "${GITHUB_OUTPUT}" - echo "sccache=off" >> "${GITHUB_OUTPUT}" - fi + with: + persist-credentials: false + ref: ${{ inputs.source_sha }} - name: Free disk space for CUDA if: matrix.name != 'cpu' uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # v2.0.0 @@ -110,19 +87,19 @@ jobs: swap-storage: false - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Configure sccache credentials - if: steps.cache.outputs.write == 'true' + if: inputs.push uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 with: script: | core.exportVariable('ACTIONS_RESULTS_URL', process.env.ACTIONS_RESULTS_URL || ''); core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || ''); - name: Log in to GHCR - if: steps.cache.outputs.write == 'true' + if: inputs.push uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + password: ${{ github.token }} - name: Docker metadata id: metadata uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 @@ -130,8 +107,8 @@ jobs: images: ghcr.io/${{ github.repository }} flavor: latest=false tags: | - type=sha,format=short,prefix=,suffix=${{ matrix.staging_suffix }} - - name: Build ${{ matrix.name }} image for ${{ matrix.platform }} + type=raw,value=${{ inputs.source_sha }}${{ matrix.staging_suffix }} + - name: Build uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . @@ -139,199 +116,13 @@ jobs: target: runtime build-args: | ${{ matrix.build_args }} - SCCACHE_GHA_ENABLED=${{ steps.cache.outputs.sccache }} + SCCACHE_GHA_ENABLED=${{ inputs.push && 'on' || 'off' }} secrets: | ACTIONS_RESULTS_URL=${{ env.ACTIONS_RESULTS_URL }} ACTIONS_RUNTIME_TOKEN=${{ env.ACTIONS_RUNTIME_TOKEN }} platforms: ${{ matrix.platform }} - push: ${{ github.event_name != 'pull_request' }} + push: ${{ inputs.push }} tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} cache-from: ${{ matrix.name == 'cpu' && format('type=gha,scope={0}', matrix.cache_scope) || format('type=registry,ref=ghcr.io/{0}:buildcache-{1}', github.repository, matrix.cache_scope) }} - cache-to: ${{ matrix.name == 'cpu' && format('type=gha,mode=max,scope={0}', matrix.cache_scope) || (steps.cache.outputs.write == 'true' && format('type=registry,ref=ghcr.io/{0}:buildcache-{1},mode=max', github.repository, matrix.cache_scope) || '') }} - - assemble: - if: github.event_name == 'push' && github.ref_type != 'tag' - needs: build - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - name: cpu - suffix: -cpu - source_suffixes: |- - -cpu-amd64 - -cpu-arm64 - expected_platforms: |- - linux/amd64 - linux/arm64 - - name: turing - suffix: -turing - source_suffixes: -turing-amd64 - expected_platforms: linux/amd64 - - name: ampere - suffix: -ampere - source_suffixes: -ampere-amd64 - expected_platforms: linux/amd64 - - name: ada-lovelace - suffix: -ada-lovelace - source_suffixes: -ada-lovelace-amd64 - expected_platforms: linux/amd64 - - name: hopper - suffix: -hopper - source_suffixes: -hopper-amd64 - expected_platforms: linux/amd64 - - name: blackwell - suffix: -blackwell - source_suffixes: |- - -blackwell-amd64 - -blackwell-arm64 - expected_platforms: |- - linux/amd64 - linux/arm64 - steps: - - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Docker metadata - id: metadata - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: ghcr.io/${{ github.repository }} - flavor: latest=false - tags: | - type=sha,format=short,prefix=,suffix=${{ matrix.suffix }},priority=300 - type=sha,format=short,prefix=,priority=300,enable=${{ matrix.name == 'cpu' }} - type=raw,value=latest${{ matrix.suffix }} - type=raw,value=latest,enable=${{ matrix.name == 'cpu' }} - - name: Assemble ${{ matrix.name }} image - env: - DESTINATION_TAGS: ${{ steps.metadata.outputs.tags }} - EXPECTED_PLATFORMS: ${{ matrix.expected_platforms }} - SOURCE_SUFFIXES: ${{ matrix.source_suffixes }} - shell: bash - run: | - set -euo pipefail - - sources=() - while IFS= read -r source_suffix; do - if [[ -n "${source_suffix}" ]]; then - sources+=("ghcr.io/${GITHUB_REPOSITORY}:${GITHUB_SHA:0:7}${source_suffix}") - fi - done <<< "${SOURCE_SUFFIXES}" - - create_args=() - while IFS= read -r destination_tag; do - if [[ -n "${destination_tag}" ]]; then - create_args+=(--tag "${destination_tag}") - fi - done <<< "${DESTINATION_TAGS}" - - docker buildx imagetools create "${create_args[@]}" "${sources[@]}" - - first_destination="${DESTINATION_TAGS%%$'\n'*}" - expected_platforms="$(printf '%s\n' "${EXPECTED_PLATFORMS}" | sort)" - actual_platforms="$( - docker buildx imagetools inspect "${first_destination}" --raw | - jq -r '.manifests[].platform | select(.architecture != "unknown") | "\(.os)/\(.architecture)"' | - sort -u - )" - if [[ "${actual_platforms}" != "${expected_platforms}" ]]; then - echo "Platform mismatch for ${first_destination}" >&2 - echo "Expected:" >&2 - printf '%s\n' "${expected_platforms}" >&2 - echo "Actual:" >&2 - printf '%s\n' "${actual_platforms}" >&2 - exit 1 - fi - - promote: - if: github.ref_type == 'tag' - runs-on: ubuntu-latest - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - name: cpu - suffix: -cpu - - name: turing - suffix: -turing - - name: ampere - suffix: -ampere - - name: ada-lovelace - suffix: -ada-lovelace - - name: hopper - suffix: -hopper - - name: blackwell - suffix: -blackwell - steps: - - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - - name: Log in to GHCR - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Docker metadata - id: metadata - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: ghcr.io/${{ github.repository }} - flavor: latest=false - tags: | - type=semver,pattern={{version}},suffix=${{ matrix.suffix }} - type=semver,pattern={{version}},enable=${{ matrix.name == 'cpu' }} - type=raw,value=latest${{ matrix.suffix }} - type=raw,value=latest,enable=${{ matrix.name == 'cpu' }} - - name: Promote ${{ matrix.name }} image - env: - DESTINATION_TAGS: ${{ steps.metadata.outputs.tags }} - shell: bash - run: | - set -euo pipefail - - source_tag="ghcr.io/${GITHUB_REPOSITORY}:${GITHUB_SHA:0:7}${{ matrix.suffix }}" - source_digest="" - - for attempt in {1..60}; do - if source_digest="$(docker buildx imagetools inspect "${source_tag}" --format '{{json .Manifest}}' 2>/dev/null | jq -r '.digest')" \ - && [[ "${source_digest}" == sha256:* ]]; then - break - fi - - if [[ "${attempt}" -eq 60 ]]; then - echo "Source image ${source_tag} was not published within 20 minutes" >&2 - exit 1 - fi - - echo "Waiting for ${source_tag} to be published (attempt ${attempt}/60)" - sleep 20 - done - - immutable_source="ghcr.io/${GITHUB_REPOSITORY}@${source_digest}" - create_args=() - while IFS= read -r destination_tag; do - if [[ -n "${destination_tag}" ]]; then - create_args+=(--tag "${destination_tag}") - fi - done <<< "${DESTINATION_TAGS}" - - docker buildx imagetools create "${create_args[@]}" "${immutable_source}" - - while IFS= read -r destination_tag; do - if [[ -z "${destination_tag}" ]]; then - continue - fi - - destination_digest="$(docker buildx imagetools inspect "${destination_tag}" --format '{{json .Manifest}}' | jq -r '.digest')" - if [[ "${destination_digest}" != "${source_digest}" ]]; then - echo "Digest mismatch for ${destination_tag}: expected ${source_digest}, got ${destination_digest}" >&2 - exit 1 - fi - done <<< "${DESTINATION_TAGS}" + cache-to: ${{ matrix.name == 'cpu' && format('type=gha,mode=max,scope={0}', matrix.cache_scope) || (inputs.push && format('type=registry,ref=ghcr.io/{0}:buildcache-{1},mode=max', github.repository, matrix.cache_scope) || '') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..28aa407 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + trufflehog: + name: TruffleHog + uses: ./.github/workflows/trufflehog.yml + with: + source_sha: ${{ github.sha }} + + lint: + name: Lint + uses: ./.github/workflows/linting.yml + with: + source_sha: ${{ github.sha }} + + tests: + name: Tests + needs: [trufflehog, lint] + uses: ./.github/workflows/tests.yml + with: + cache_scope: ${{ github.event_name }} + source_sha: ${{ github.sha }} + secrets: + HF_TOKEN: ${{ github.event_name == 'push' && secrets.HF_TOKEN || '' }} + + build: + name: Build + needs: tests + if: github.ref_type != 'tag' + permissions: + actions: write + contents: read + packages: write + uses: ./.github/workflows/build.yml + with: + push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + source_sha: ${{ github.sha }} + + push: + name: Push + needs: [tests, build] + if: >- + always() && + needs.tests.result == 'success' && + github.event_name == 'push' && + (github.ref == 'refs/heads/main' || github.ref_type == 'tag') && + (github.ref_type == 'tag' || needs.build.result == 'success') + permissions: + contents: read + packages: write + uses: ./.github/workflows/push.yml + with: + release_tag: ${{ github.ref_type == 'tag' && github.ref_name || '' }} + source_sha: ${{ github.sha }} + + publish: + name: Publish + needs: tests + if: github.ref_type == 'tag' + uses: ./.github/workflows/publish.yml + with: + sha: ${{ github.sha }} + tag: ${{ github.ref_name }} + secrets: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/clippy.yml b/.github/workflows/linting.yml similarity index 54% rename from .github/workflows/clippy.yml rename to .github/workflows/linting.yml index 0cacae3..98d1d5c 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/linting.yml @@ -1,26 +1,29 @@ -name: Clippy +name: Lint on: - push: - branches: [main] - pull_request: - branches: [main] + workflow_call: + inputs: + source_sha: + required: true + type: string permissions: contents: read jobs: - clippy: + lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ inputs.source_sha }} - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cargo/registry ~/.cargo/git - target - key: clippy-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} - restore-keys: clippy-${{ runner.os }}- + key: lint-v2-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: lint-v2-${{ runner.os }}- + - run: cargo fmt --check - run: cargo clippy --locked --no-default-features --features cpu --all-targets -- -D warnings - - run: cargo test --locked --no-default-features --features cpu diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 961b75a..3d36f6d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,31 +1,59 @@ -name: Publish crate +name: Publish on: - push: - tags: ["v*"] + workflow_call: + inputs: + tag: + required: true + type: string + sha: + required: true + type: string + secrets: + CARGO_REGISTRY_TOKEN: + required: true + workflow_dispatch: + inputs: + tag: + description: Release tag that passed linting and tests + required: true + type: string + sha: + description: Tested commit to publish + required: true + type: string permissions: contents: read +concurrency: + group: crates-${{ inputs.tag }} + cancel-in-progress: false + jobs: publish: + name: Publish runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: publish-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} - restore-keys: publish-${{ runner.os }}- - - name: Verify release version + ref: ${{ inputs.sha }} + fetch-depth: 0 + - name: Verify tested release + env: + RELEASE_SHA: ${{ inputs.sha }} + RELEASE_TAG: ${{ inputs.tag }} shell: bash run: | + set -euo pipefail + case "${RELEASE_TAG}" in + v*) ;; + *) exit 1 ;; + esac + test "$(git rev-list -n 1 "refs/tags/${RELEASE_TAG}")" = "${RELEASE_SHA}" crate_version="$(cargo metadata --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "sys1") | .version')" - test "${GITHUB_REF_NAME}" = "v${crate_version}" + test "${RELEASE_TAG}" = "v${crate_version}" - name: Publish to crates.io - run: cargo publish --locked env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish --locked diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..0a3e0e5 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,202 @@ +name: Push + +on: + workflow_call: + inputs: + release_tag: + required: false + type: string + default: "" + source_sha: + required: true + type: string + +jobs: + commit: + name: ${{ matrix.name }} + if: inputs.release_tag == '' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: cpu + suffix: -cpu + source_suffixes: |- + -cpu-amd64 + -cpu-arm64 + expected_platforms: |- + linux/amd64 + linux/arm64 + - name: turing + suffix: -turing + source_suffixes: -turing-amd64 + expected_platforms: linux/amd64 + - name: ampere + suffix: -ampere + source_suffixes: -ampere-amd64 + expected_platforms: linux/amd64 + - name: ada-lovelace + suffix: -ada-lovelace + source_suffixes: -ada-lovelace-amd64 + expected_platforms: linux/amd64 + - name: hopper + suffix: -hopper + source_suffixes: -hopper-amd64 + expected_platforms: linux/amd64 + - name: blackwell + suffix: -blackwell + source_suffixes: |- + -blackwell-amd64 + -blackwell-arm64 + expected_platforms: |- + linux/amd64 + linux/arm64 + steps: + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Docker metadata + id: metadata + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/${{ github.repository }} + flavor: latest=false + tags: | + type=raw,value=${{ inputs.source_sha }}${{ matrix.suffix }} + type=raw,value=${{ inputs.source_sha }},enable=${{ matrix.name == 'cpu' }} + type=raw,value=main${{ matrix.suffix }} + type=raw,value=main,enable=${{ matrix.name == 'cpu' }} + - name: Push + env: + DESTINATION_TAGS: ${{ steps.metadata.outputs.tags }} + EXPECTED_PLATFORMS: ${{ matrix.expected_platforms }} + SOURCE_SHA: ${{ inputs.source_sha }} + SOURCE_SUFFIXES: ${{ matrix.source_suffixes }} + shell: bash + run: | + set -euo pipefail + + sources=() + while IFS= read -r source_suffix; do + if [[ -n "${source_suffix}" ]]; then + sources+=("ghcr.io/${GITHUB_REPOSITORY}:${SOURCE_SHA}${source_suffix}") + fi + done <<< "${SOURCE_SUFFIXES}" + + create_args=() + while IFS= read -r destination_tag; do + if [[ -n "${destination_tag}" ]]; then + create_args+=(--tag "${destination_tag}") + fi + done <<< "${DESTINATION_TAGS}" + + docker buildx imagetools create "${create_args[@]}" "${sources[@]}" + + first_destination="${DESTINATION_TAGS%%$'\n'*}" + expected_platforms="$(printf '%s\n' "${EXPECTED_PLATFORMS}" | sort)" + actual_platforms="$( + docker buildx imagetools inspect "${first_destination}" --raw | + jq -r '.manifests[].platform | select(.architecture != "unknown") | "\(.os)/\(.architecture)"' | + sort -u + )" + if [[ "${actual_platforms}" != "${expected_platforms}" ]]; then + echo "Platform mismatch for ${first_destination}" >&2 + echo "Expected:" >&2 + printf '%s\n' "${expected_platforms}" >&2 + echo "Actual:" >&2 + printf '%s\n' "${actual_platforms}" >&2 + exit 1 + fi + + release: + name: ${{ matrix.name }} + if: inputs.release_tag != '' + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - name: cpu + suffix: -cpu + - name: turing + suffix: -turing + - name: ampere + suffix: -ampere + - name: ada-lovelace + suffix: -ada-lovelace + - name: hopper + suffix: -hopper + - name: blackwell + suffix: -blackwell + steps: + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Docker metadata + id: metadata + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ghcr.io/${{ github.repository }} + flavor: latest=false + tags: | + type=semver,pattern={{version}},value=${{ inputs.release_tag }},suffix=${{ matrix.suffix }} + type=semver,pattern={{version}},value=${{ inputs.release_tag }},enable=${{ matrix.name == 'cpu' }} + type=raw,value=latest${{ matrix.suffix }} + type=raw,value=latest,enable=${{ matrix.name == 'cpu' }} + - name: Push + env: + DESTINATION_TAGS: ${{ steps.metadata.outputs.tags }} + SOURCE_SHA: ${{ inputs.source_sha }} + shell: bash + run: | + set -euo pipefail + + source_tag="ghcr.io/${GITHUB_REPOSITORY}:${SOURCE_SHA}${{ matrix.suffix }}" + source_digest="" + + for attempt in {1..60}; do + if source_digest="$(docker buildx imagetools inspect "${source_tag}" --format '{{json .Manifest}}' 2>/dev/null | jq -r '.digest')" \ + && [[ "${source_digest}" == sha256:* ]]; then + break + fi + + if [[ "${attempt}" -eq 60 ]]; then + echo "Source image ${source_tag} was not published within 20 minutes" >&2 + exit 1 + fi + + echo "Waiting for ${source_tag} to be published (attempt ${attempt}/60)" + sleep 20 + done + + immutable_source="ghcr.io/${GITHUB_REPOSITORY}@${source_digest}" + create_args=() + while IFS= read -r destination_tag; do + if [[ -n "${destination_tag}" ]]; then + create_args+=(--tag "${destination_tag}") + fi + done <<< "${DESTINATION_TAGS}" + + docker buildx imagetools create "${create_args[@]}" "${immutable_source}" + + while IFS= read -r destination_tag; do + if [[ -z "${destination_tag}" ]]; then + continue + fi + + destination_digest="$(docker buildx imagetools inspect "${destination_tag}" --format '{{json .Manifest}}' | jq -r '.digest')" + if [[ "${destination_digest}" != "${source_digest}" ]]; then + echo "Digest mismatch for ${destination_tag}: expected ${source_digest}, got ${destination_digest}" >&2 + exit 1 + fi + done <<< "${DESTINATION_TAGS}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b9541b4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,60 @@ +name: Tests + +on: + workflow_call: + inputs: + cache_scope: + required: true + type: string + source_sha: + required: true + type: string + secrets: + HF_TOKEN: + required: false + +permissions: + contents: read + +jobs: + cpu: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ inputs.source_sha }} + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/huggingface/hub + ~/.cargo/registry + ~/.cargo/git + key: test-v2-${{ inputs.cache_scope }}-cpu-${{ hashFiles('Cargo.lock') }}-aa8c91ca088ec597df95a0d1c76b3063cb2ae5e8 + restore-keys: test-v2-${{ inputs.cache_scope }}-cpu- + - name: Test + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: cargo test --release --locked --no-default-features --features cpu + + metal: + runs-on: macos-15 + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ inputs.source_sha }} + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cache/huggingface/hub + ~/.cargo/registry + ~/.cargo/git + key: test-v2-${{ inputs.cache_scope }}-metal-${{ hashFiles('Cargo.lock') }}-aa8c91ca088ec597df95a0d1c76b3063cb2ae5e8 + restore-keys: test-v2-${{ inputs.cache_scope }}-metal- + - name: Test + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: cargo test --release --locked --no-default-features --features metal diff --git a/.github/workflows/trufflehog.yml b/.github/workflows/trufflehog.yml index dc729d3..d5b7514 100644 --- a/.github/workflows/trufflehog.yml +++ b/.github/workflows/trufflehog.yml @@ -1,10 +1,11 @@ name: Secret Leaks on: - push: - branches: [main] - pull_request: - branches: [main] + workflow_call: + inputs: + source_sha: + required: true + type: string permissions: contents: read @@ -17,6 +18,8 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false + ref: ${{ inputs.source_sha }} - name: Secret scanning uses: trufflesecurity/trufflehog@f714bf454f350590f4a24c3ddb1aef02c35bf5b6 # v3.97.5 with: diff --git a/Cargo.lock b/Cargo.lock index bf94773..2ef1fdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2175,6 +2175,21 @@ dependencies = [ "web-time", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "pest", + "pest_derive", + "serde", + "similar", + "tempfile", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -2893,6 +2908,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" +dependencies = [ + "pest", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -3813,6 +3870,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.12" @@ -3950,6 +4013,7 @@ dependencies = [ "candle-nn", "clap", "hf-hub", + "insta", "serde", "serde_json", "tokenizers 1.0.0-rc.2", @@ -4500,6 +4564,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "ug" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 12c3116..e1974db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ candle-nn = { version = "0.11.0", features = ["accelerate"] } candle-kernels = { git = "https://github.com/huggingface/candle", rev = "e68b659fd096ed0947855d43b2eaa55634759c56" } [dev-dependencies] +insta = { version = "1.48.0", features = ["redactions", "yaml"] } tower = { version = "0.5", features = ["util"] } [features] diff --git a/README.md b/README.md index 58fe118..cd425d2 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ And, just query your Jev-compatible API at `/v1/systemone` (or `/v1/decide`). curl http://localhost:3000/v1/systemone \ -H "Content-Type: application/json" \ -d '{ + "model": "convaiinnovations/laya", "state": { "message": "I was charged twice for invoice 4411. Please refund me today." }, diff --git a/src/models/laya.rs b/src/models/laya.rs index ddb20a2..57049e9 100644 --- a/src/models/laya.rs +++ b/src/models/laya.rs @@ -798,6 +798,76 @@ fn round4(value: f32) -> f64 { mod tests { use super::*; + #[tokio::test] + async fn fp32_logits_and_probabilities() -> anyhow::Result<()> { + let path = crate::hub::download( + "convaiinnovations/laya", + "aa8c91ca088ec597df95a0d1c76b3063cb2ae5e8", + ) + .await?; + + let model = Laya::load(&path, DType::F32)?; + let request: DecisionRequest = serde_json::from_value(json!({ + "state": { + "message": "I was charged twice for invoice 4411. Please refund me today.", + "account_tier": "enterprise" + }, + "questions": { + "route": { + "type": "choice", + "instructions": "Where should this ticket go?", + "criteria": { + "billing": "payments, refunds, invoices", + "bug": "the product is broken", + "account": "login or access" + } + }, + "urgency": { + "type": "score", + "instructions": "How urgent is this message?", + "criteria": [ + "routine, no rush", + "today", + "urgent", + "critical, about to churn" + ] + }, + "escalate": { + "type": "noul", + "instructions": "Escalate to a human immediately?" + } + } + }))?; + + let prepared = model.prepare(request).expect("prepare the test request"); + let logits = model.forward(std::slice::from_ref(&prepared))?; + let probabilities: Vec<_> = prepared + .items + .iter() + .zip(&logits) + .map(|(item, logits)| { + let bucket = bucket(item.question.kind, logits.len()); + let temperature = model + .config + .temperature_by_options + .get(&bucket) + .copied() + .unwrap_or(model.config.temperature[item.question.kind]) + .clamp(0.5, 5.0); + probability(logits, temperature) + }) + .collect(); + + insta::assert_yaml_snapshot!("laya_fp32_logits", logits, { + "[][]" => insta::rounded_redaction(3), + }); + insta::assert_yaml_snapshot!("laya_fp32_probabilities", probabilities, { + "[][]" => insta::rounded_redaction(4), + }); + + Ok(()) + } + fn item(id: &str, kind: usize, ids: &[u32]) -> Item { Item { question: Question { diff --git a/src/models/modernbert.rs b/src/models/modernbert.rs index d768ca7..9b240a1 100644 --- a/src/models/modernbert.rs +++ b/src/models/modernbert.rs @@ -407,3 +407,91 @@ fn local_attention_mask( .collect(); Tensor::from_vec(mask, (length, length), device)?.to_dtype(dtype) } + +#[cfg(test)] +mod tests { + use super::*; + use candle_core::IndexOp; + + #[test] + fn local_attention_mask_only_exposes_nearby_tokens() -> Result<()> { + let mask = local_attention_mask(4, 1, DType::F32, &Device::Cpu)?.to_vec2::()?; + + assert_eq!( + mask, + vec![ + vec![0.0, 0.0, f32::NEG_INFINITY, f32::NEG_INFINITY], + vec![0.0, 0.0, 0.0, f32::NEG_INFINITY], + vec![f32::NEG_INFINITY, 0.0, 0.0, 0.0], + vec![f32::NEG_INFINITY, f32::NEG_INFINITY, 0.0, 0.0], + ] + ); + Ok(()) + } + + #[test] + fn global_attention_mask_hides_padding_tokens() -> Result<()> { + let mask = Tensor::new(&[[1u32, 1, 0]], &Device::Cpu)?; + let mask = global_attention_mask(&mask, 2, DType::F32)? + .flatten_all()? + .to_vec1::()?; + + assert_eq!(mask, vec![0.0, 0.0, f32::MIN, 0.0, 0.0, f32::MIN]); + Ok(()) + } + + #[tokio::test] + async fn fp32_hidden_states() -> anyhow::Result<()> { + let path = crate::hub::download( + "convaiinnovations/laya", + "aa8c91ca088ec597df95a0d1c76b3063cb2ae5e8", + ) + .await?; + + let device = crate::device::load()?; + let config = Config::load(&path.join("encoder/config.json"))?; + let weights = path.join("model.safetensors"); + let vb = unsafe { VarBuilder::from_mmaped_safetensors(&[weights], DType::F32, &device)? }; + let vb = vb.rename_f(|name| { + name.strip_prefix("model.") + .map(|name| format!("encoder.{name}")) + .unwrap_or_else(|| name.to_owned()) + }); + let encoder = Encoder::load(vb, &config, DType::F32)?; + + let tokenizer_path = path.join("tokenizer/tokenizer.json"); + let tokenizer = crate::tokenizer::from_json(&fs::read(tokenizer_path)?)?; + let cls_id = tokenizer + .token_to_id("[CLS]") + .ok_or_else(|| anyhow::anyhow!("tokenizer is missing [CLS]"))?; + let sep_id = tokenizer + .token_to_id("[SEP]") + .ok_or_else(|| anyhow::anyhow!("tokenizer is missing [SEP]"))?; + let pad_id = tokenizer + .token_to_id("[PAD]") + .ok_or_else(|| anyhow::anyhow!("tokenizer is missing [PAD]"))?; + + let mut ids = vec![cls_id]; + ids.extend(tokenizer.encode("What is Deep Learning?")?); + ids.push(sep_id); + + let mut mask = vec![1f32; ids.len()]; + ids.resize(32, pad_id); + mask.resize(32, 0.0); + + let length = ids.len(); + let ids = Tensor::from_vec(ids, (1, length), &device)?; + let mask = Tensor::from_vec(mask, (1, length), &device)?; + let hidden = encoder.forward(&ids, &mask, true)?; + + insta::assert_yaml_snapshot!( + "modernbert_fp32_hidden_states", + hidden.i((0, 0, 0..128))?.to_vec1::()?, + { + "[]" => insta::rounded_redaction(3), + } + ); + + Ok(()) + } +} diff --git a/src/models/snapshots/sys1__models__laya__tests__laya_fp32_logits.snap b/src/models/snapshots/sys1__models__laya__tests__laya_fp32_logits.snap new file mode 100644 index 0000000..0220f9a --- /dev/null +++ b/src/models/snapshots/sys1__models__laya__tests__laya_fp32_logits.snap @@ -0,0 +1,13 @@ +--- +source: src/models/laya.rs +expression: logits +--- +- - 3.048 + - -2.88 + - -3.174 +- - -1.368 + - 4.087 + - 0.282 + - -2.044 +- - 0.87 + - 0.414 diff --git a/src/models/snapshots/sys1__models__laya__tests__laya_fp32_probabilities.snap b/src/models/snapshots/sys1__models__laya__tests__laya_fp32_probabilities.snap new file mode 100644 index 0000000..c71a14c --- /dev/null +++ b/src/models/snapshots/sys1__models__laya__tests__laya_fp32_probabilities.snap @@ -0,0 +1,13 @@ +--- +source: src/models/laya.rs +expression: probabilities +--- +- - 0.9402 + - 0.0324 + - 0.0274 +- - 0.012 + - 0.9363 + - 0.0448 + - 0.007 +- - 0.5572 + - 0.4428 diff --git a/src/models/snapshots/sys1__models__modernbert__tests__modernbert_fp32_hidden_states.snap b/src/models/snapshots/sys1__models__modernbert__tests__modernbert_fp32_hidden_states.snap new file mode 100644 index 0000000..6722bcc --- /dev/null +++ b/src/models/snapshots/sys1__models__modernbert__tests__modernbert_fp32_hidden_states.snap @@ -0,0 +1,132 @@ +--- +source: src/models/modernbert.rs +expression: "hidden.i((0, 0, 0..128))?.to_vec1::()?" +--- +- -0.648 +- -0.029 +- -0.685 +- -0.476 +- 0.853 +- -0.637 +- -0.019 +- 0.5 +- 0.065 +- -0.294 +- 0.315 +- 0.305 +- 0.39 +- -0.645 +- -0.379 +- 0.116 +- -0.542 +- 0.094 +- 0.054 +- -0.275 +- 0.155 +- -0.766 +- -0.16 +- 0.967 +- 0.423 +- 0.102 +- -0.644 +- -0.655 +- 0.666 +- 0.045 +- 0.093 +- -0.285 +- -0.281 +- 0.501 +- 0.06 +- 0.236 +- -0.364 +- -0.344 +- -0.236 +- 0.572 +- -0.101 +- -0.874 +- 0.175 +- 0.305 +- 0.519 +- 0.298 +- 0.528 +- -0.867 +- 0.675 +- 0.056 +- -0.294 +- 0.139 +- 0.292 +- 0.145 +- -0.182 +- -0.736 +- 0.059 +- -0.186 +- 0.002 +- 0.258 +- 0.714 +- -0.346 +- -0.14 +- -0.162 +- -0.963 +- 0.379 +- 0.322 +- 0.185 +- -0.117 +- -0.075 +- -0.753 +- 0.073 +- 0.55 +- -0.004 +- -0.591 +- -0.076 +- -0.274 +- 0.249 +- 0.277 +- -0.05 +- 0.003 +- -0.418 +- -0.65 +- -0.654 +- 0.469 +- -0.516 +- -0.586 +- 0.349 +- -0.956 +- 0.35 +- -0.326 +- 0.506 +- 0.147 +- -0.492 +- -0.524 +- 0.377 +- 0.341 +- -1.196 +- -0.696 +- -0.089 +- 2.355 +- -0.271 +- -0.653 +- -0.341 +- -0.922 +- 0.045 +- -0.251 +- 0.14 +- -0.759 +- 0.234 +- -0.774 +- -0.411 +- -0.155 +- -0.073 +- 0.062 +- -1.014 +- -0.102 +- 0.027 +- 0.122 +- -0.047 +- 0.195 +- -0.13 +- 0.149 +- 0.58 +- -0.343 +- -0.767 +- 0.254 +- -0.863