--- a
+++ b/.github/workflows/unit-tests.yml
@@ -0,0 +1,225 @@
+name: "BioNemo Image Build and Unit Tests"
+
+on:
+  push:
+    branches:
+      - main
+      - "pull-request/[0-9]+"
+      - "dependabot/**"
+  merge_group:
+    types: [checks_requested]
+  schedule:
+    - cron: "0 7 * * *" # Runs at 7 AM UTC daily (12 AM MST)
+
+defaults:
+  run:
+    shell: bash -x -e -u -o pipefail {0}
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  pre-commit:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+          submodules: "recursive"
+      - uses: actions/setup-python@v5
+        with:
+          python-version: "3.12"
+          cache: "pip"
+      - run: pip install -r requirements-dev.txt
+      - run: ./ci/scripts/static_checks.sh
+
+  # With copy-pr-bot, we need to get the PR labels from the PR API rather than from the event metadata.
+  get-pr-labels:
+    runs-on: ubuntu-latest
+    outputs:
+      labels: ${{ steps.get-labels.outputs.labels }}
+    steps:
+      - name: Get PR number from branch
+        if: startsWith(github.ref, 'refs/heads/pull-request/')
+        id: get-pr-num
+        run: |
+          PR_NUM=$(echo ${{ github.ref_name }} | grep -oE '[0-9]+$')
+          echo "pr_num=$PR_NUM" >> $GITHUB_OUTPUT
+
+      - name: Get PR labels
+        id: get-labels
+        if: startsWith(github.ref, 'refs/heads/pull-request/')
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          LABELS=$(gh api repos/${{ github.repository }}/pulls/${{ steps.get-pr-num.outputs.pr_num }} --jq '[.labels[].name]' || echo "[]")
+          echo "labels=$LABELS" >> $GITHUB_OUTPUT
+
+      - name: Set empty labels for non-PR branches
+        if: ${{ !startsWith(github.ref, 'refs/heads/pull-request/') }}
+        id: get-labels-empty
+        run: echo "labels=[]" >> $GITHUB_OUTPUT
+
+  build-bionemo-image:
+    needs:
+      - pre-commit
+      - get-pr-labels
+    runs-on: linux-amd64-cpu16
+    if: ${{ !contains(fromJSON(needs.get-pr-labels.outputs.labels || '[]'), 'SKIP_CI') }}
+    steps:
+      - name: Login to Docker Hub
+        uses: docker/login-action@v3
+        with:
+          username: ${{ vars.DOCKER_USERNAME }}
+          password: ${{ secrets.DOCKER_PASSWORD }}
+
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          submodules: "recursive"
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+
+      - name: Docker Metadata
+        id: metadata
+        uses: docker/metadata-action@v5
+        with:
+          images: svcbionemo023/bionemo-framework
+          tags: |
+            type=schedule
+            type=ref,event=branch
+            type=ref,event=tag
+            type=ref,event=pr
+            type=raw,value=${{ github.run_id }}
+
+      # This action sets up our cache-from and cache-to flags appropriately; see the README of this action for more
+      # info. It doesn't seem to cache correctly for merge_group events, so we need to add that as an extra argument in
+      # the step below. There's probably a slight optimization to be had here by caching from the pr- caches for
+      # merge_group events. See https://github.com/int128/docker-build-cache-config-action/issues/1005 for more info.
+      - uses: int128/docker-build-cache-config-action@v1
+        id: cache
+        with:
+          image: svcbionemo023/bionemo-build-cache
+
+      - name: Build and push
+        uses: docker/build-push-action@v5
+        with:
+          push: true
+          tags: ${{ steps.metadata.outputs.tags }}
+          labels: ${{ steps.metadata.outputs.labels }}
+          cache-from: |
+            ${{ steps.cache.outputs.cache-from }}
+            type=registry,ref=svcbionemo023/bionemo-build-cache:main
+          cache-to: ${{ steps.cache.outputs.cache-to }}
+
+
+  run-tests:
+    needs:
+      - build-bionemo-image
+      - get-pr-labels
+    runs-on: linux-amd64-gpu-l4-latest-1
+    container:
+      image: svcbionemo023/bionemo-framework:${{ github.run_id }}
+      credentials:
+        username: ${{ vars.DOCKER_USERNAME }}
+        password: ${{ secrets.DOCKER_PASSWORD }}
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Run tests
+        # Tests in this stage generate code coverage metrics for the repository
+        # Coverage data is uploaded to Codecov in subsequent stages
+        env:
+          BIONEMO_DATA_SOURCE: ngc
+        run: ./ci/scripts/run_pytest.sh --no-nbval --skip-slow
+
+      - name: Upload coverage to Codecov
+        # Don't run coverage on merge queue or nightly CI to avoid duplicating reports
+        # to codecov. See https://github.com/matplotlib/napari-matplotlib/issues/155
+        if: github.event_name != 'merge_group' && github.event_name != 'schedule'
+        uses: codecov/codecov-action@v5
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+
+      - name: Upload test results to Codecov
+        # Don't run coverage on merge queue or nightly CI to avoid duplicating reports
+        # to codecov. See https://github.com/matplotlib/napari-matplotlib/issues/155
+        if: ${{ !cancelled() && github.event_name != 'merge_group' && github.event_name != 'schedule' }}
+        uses: codecov/test-results-action@v1
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+
+  run-slow-tests:
+    needs:
+      - build-bionemo-image
+      - get-pr-labels
+    runs-on: linux-amd64-gpu-l4-latest-1
+    container:
+      image: svcbionemo023/bionemo-framework:${{ github.run_id }}
+      credentials:
+        username: ${{ vars.DOCKER_USERNAME }}
+        password: ${{ secrets.DOCKER_PASSWORD }}
+    if: |
+      github.event_name == 'schedule' || github.event_name == 'merge_group' ||
+      contains(fromJSON(needs.get-pr-labels.outputs.labels || '[]'), 'INCLUDE_SLOW_TESTS')
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Run slow tests
+        env:
+          BIONEMO_DATA_SOURCE: ngc
+        # Not every sub-package has slow tests, and since some sub-packages have tests under the same name we need
+        #  to run package by package like we do with the fast tests.
+        run: ./ci/scripts/run_pytest.sh --no-nbval --only-slow --allow-no-tests
+
+
+  run-notebooks-docs:
+    needs:
+      - build-bionemo-image
+      - get-pr-labels
+    runs-on: linux-amd64-gpu-l4-latest-1
+    if: |
+      github.event_name == 'schedule' || github.event_name == 'merge_group' ||
+      contains(fromJSON(needs.get-pr-labels.outputs.labels || '[]'), 'INCLUDE_NOTEBOOKS_TESTS')
+    container:
+      image: svcbionemo023/bionemo-framework:${{ github.run_id }}
+      credentials:
+        username: ${{ vars.DOCKER_USERNAME }}
+        password: ${{ secrets.DOCKER_PASSWORD }}
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Run notebook tests
+        env:
+          BIONEMO_DATA_SOURCE: ngc
+          # this variable should be used in the notebooks to run a subset of the model layers or a smaller model/dataset
+          FAST_CI_MODE: true
+        run: |
+          pytest -v --nbval-lax -p no:python docs/ sub-packages/
+
+  verify-tests-status:
+    # Base on the status of this job, the unit-tests workflow succeeds or fails
+    # This steps checks the status of all test jobs and fails if any of them failed or were cancelled.
+    # It is a workaround for the lack of a built-in feature to finalize a pipeline by checking the status of multiple jobs
+    needs: # List all your run-*-test jobs
+      - run-tests
+      - run-slow-tests
+      - run-notebooks-docs
+      # Add all other run-*-test jobs
+    runs-on: ubuntu-latest
+    if: always() # This ensures the job runs even if previous jobs fail
+    steps:
+      - name: Check test job statuses
+        run: |
+          if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
+            echo "Some test jobs have failed or been cancelled!"
+            exit 1
+          else
+            echo "All test jobs have completed successfully or been skipped!"
+            exit 0
+          fi