diff --git a/.dockerignore b/.dockerignore index f4a02484ebfe..3c16d71b2263 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,10 @@ node_modules **/node_modules .venv **/.venv +.notebooklm-cli-venv/ +.notebooklm-playwright/ +.pip-cache/ +.uv-cache/ # Built artifacts that are regenerated inside the image. Excluded so local # rebuilds on the developer's machine don't invalidate the npm-install layer @@ -25,6 +29,8 @@ ui-tui/packages/hermes-ink/dist/ # Runtime data (bind-mounted at /opt/data; must not leak into build context) data/ +.hermes-docker/ +.notebooklm-home/ # Compose/profile runtime state (bind-mounted; avoid ownership/secret issues) hermes-config/ diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index 939215ed4499..de38fcaae9a6 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -3,11 +3,9 @@ name: Contributor Attribution Check on: pull_request: branches: [main] - paths: - # Only run when code files change (not docs-only PRs) - - '*.py' - - '**/*.py' - - '.github/workflows/contributor-check.yml' + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). permissions: contents: read @@ -20,7 +18,21 @@ jobs: with: fetch-depth: 0 # Full history needed for git log + - name: Check if relevant files changed + id: filter + run: | + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true) + if [ -n "$CHANGED" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "No Python files changed, skipping attribution check." + fi + - name: Check for unmapped contributor emails + if: steps.filter.outputs.run == 'true' run: | # Get the merge base between this PR and main MERGE_BASE=$(git merge-base origin/main HEAD) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 823496157a97..82acaa6667d7 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -22,7 +22,12 @@ concurrency: jobs: deploy-vercel: - if: github.event_name == 'release' + # Triggered automatically on release publish (production cuts) and + # manually via `gh workflow run deploy-site.yml` when an out-of-band + # main commit needs to ship live before the next release tag — e.g. + # a skills-index PR that doesn't touch website/** paths and so + # doesn't auto-deploy via the deploy-docs path. + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - name: Trigger Vercel Deploy diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c0e69bcf3d17..7dd0c799f0e7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -28,8 +28,7 @@ permissions: contents: read # Concurrency: push/release runs are NEVER cancelled so every merge gets -# its own :main or release-tagged image. :latest is guarded separately -# by the move-latest job. PR runs reuse a PR-scoped group with +# its own image. PR runs reuse a PR-scoped group with # cancel-in-progress: true so rapid pushes to the same PR collapse to the # latest commit. concurrency: @@ -72,6 +71,8 @@ jobs: load: true platforms: linux/amd64 tags: ${{ env.IMAGE_NAME }}:test + build-args: | + HERMES_GIT_SHA=${{ github.sha }} cache-from: type=gha,scope=docker-amd64 cache-to: type=gha,mode=max,scope=docker-amd64 @@ -140,12 +141,6 @@ jobs: # Push amd64 by digest only (no tag). The merge job assembles the # tagged manifest list. `push-by-digest=true` is docker's recommended # pattern for multi-runner multi-platform builds. - # - # We apply the OCI revision label here (and again on arm64) because - # the move-latest job reads it off the linux/amd64 sub-manifest - # config of the floating tag to decide whether it's safe to advance. - # The label must be on each per-arch image — manifest lists themselves - # don't carry image config labels. - name: Push amd64 by digest id: push if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' @@ -156,6 +151,8 @@ jobs: platforms: linux/amd64 labels: | org.opencontainers.image.revision=${{ github.sha }} + build-args: | + HERMES_GIT_SHA=${{ github.sha }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=docker-amd64 cache-to: type=gha,mode=max,scope=docker-amd64 @@ -199,10 +196,26 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - # Build once, load into the local daemon for smoke testing. Cached - # to gha with a per-arch scope; the push step below reuses every - # layer from this build. - - name: Build image (arm64, smoke test) + # Build once, load into the local daemon for smoke testing. PR arm64 + # builds deliberately avoid the gha cache: cold-cache arm64 builds can + # outlive GitHub's short-lived Azure cache SAS token, then fail while + # reading or writing cache blobs before the smoke test can run. + - name: Build image (arm64, smoke test, uncached PR) + if: github.event_name == 'pull_request' + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + file: Dockerfile + load: true + platforms: linux/arm64 + tags: ${{ env.IMAGE_NAME }}:test + build-args: | + HERMES_GIT_SHA=${{ github.sha }} + + # Main/release builds still use the per-arch gha cache so the digest + # push below can reuse layers from this smoke-test build. + - name: Build image (arm64, smoke test, cached publish) + if: github.event_name != 'pull_request' uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . @@ -210,6 +223,8 @@ jobs: load: true platforms: linux/arm64 tags: ${{ env.IMAGE_NAME }}:test + build-args: | + HERMES_GIT_SHA=${{ github.sha }} cache-from: type=gha,scope=docker-arm64 cache-to: type=gha,mode=max,scope=docker-arm64 @@ -235,6 +250,8 @@ jobs: platforms: linux/arm64 labels: | org.opencontainers.image.revision=${{ github.sha }} + build-args: | + HERMES_GIT_SHA=${{ github.sha }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true cache-from: type=gha,scope=docker-arm64 cache-to: type=gha,mode=max,scope=docker-arm64 @@ -258,30 +275,17 @@ jobs: # --------------------------------------------------------------------------- # Stitch both per-arch digests into a single tagged multi-arch manifest. # This is a registry-side operation — no building, no layer re-push — - # so it runs in ~30 seconds. On main pushes it produces :main; on - # releases it produces :. + # so it runs in ~30 seconds. # - # For main pushes the ancestor check runs BEFORE the manifest push so - # we never overwrite :main with an older commit. The top-level - # concurrency group (`docker-${{ github.ref }}` with - # `cancel-in-progress: false`) already serialises runs per ref; the - # ancestor check is defense-in-depth. + # On main pushes: tags both :main and :latest. + # On releases: tags :. # --------------------------------------------------------------------------- merge: if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') runs-on: ubuntu-latest needs: [build-amd64, build-arm64] timeout-minutes: 10 - outputs: - pushed_release_tag: ${{ steps.mark_release_pushed.outputs.pushed }} - release_tag: ${{ steps.tag.outputs.tag }} steps: - - name: Checkout code - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1000 - - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -298,86 +302,7 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # Read the git revision label off the current :main manifest, then - # use `git merge-base --is-ancestor` to check whether our commit is - # a descendant of it. If :main doesn't exist yet, or its label is - # missing, we treat that as "safe to publish". If another run - # already advanced :main past us (or diverged), we skip and leave - # it alone. - - name: Decide whether to move :main - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - id: main_check - run: | - set -euo pipefail - image=nousresearch/hermes-agent - - image_json=$( - docker buildx imagetools inspect "${image}:main" \ - --format '{{ json (index .Image "linux/amd64") }}' \ - 2>/dev/null || true - ) - - if [ -z "${image_json}" ]; then - echo "No existing :main (or inspect failed) — safe to publish." - echo "push_main=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - current_sha=$( - printf '%s' "${image_json}" \ - | jq -r '.config.Labels."org.opencontainers.image.revision" // ""' - ) - - if [ -z "${current_sha}" ]; then - echo "Registry :main has no revision label — safe to publish." - echo "push_main=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "Registry :main is at ${current_sha}" - echo "This run is at ${GITHUB_SHA}" - - if [ "${current_sha}" = "${GITHUB_SHA}" ]; then - echo ":main already points at our SHA — nothing to do." - echo "push_main=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then - git fetch --no-tags --prune origin \ - "+refs/heads/main:refs/remotes/origin/main" \ - || true - fi - - if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then - echo "Registry :main points at an unknown commit (${current_sha}); refusing to overwrite." - echo "push_main=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then - echo "Our commit is a descendant of :main — safe to advance." - echo "push_main=true" >> "$GITHUB_OUTPUT" - else - echo "Another run advanced :main past us (or diverged) — leaving it alone." - echo "push_main=false" >> "$GITHUB_OUTPUT" - fi - - # Compute the tag for this run. Main pushes tag directly as :main - # (no per-commit SHA tags); releases use the release tag name. - - name: Compute tag - id: tag - run: | - if [ "${{ github.event_name }}" = "release" ]; then - echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" - else - echo "tag=main" >> "$GITHUB_OUTPUT" - fi - - # Gate the manifest push on the ancestor check for main pushes. - # For releases there is no gate — the check doesn't even run. - name: Create manifest list and push - if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true' working-directory: /tmp/digests run: | set -euo pipefail @@ -385,137 +310,26 @@ jobs: for digest_file in *; do args+=("${IMAGE_NAME}@sha256:${digest_file}") done - docker buildx imagetools create \ - -t "${IMAGE_NAME}:${TAG}" \ - "${args[@]}" + if [ "${{ github.event_name }}" = "release" ]; then + TAG="${{ github.event.release.tag_name }}" + docker buildx imagetools create \ + -t "${IMAGE_NAME}:${TAG}" \ + "${args[@]}" + else + docker buildx imagetools create \ + -t "${IMAGE_NAME}:main" \ + -t "${IMAGE_NAME}:latest" \ + "${args[@]}" + fi env: IMAGE_NAME: ${{ env.IMAGE_NAME }} - TAG: ${{ steps.tag.outputs.tag }} - name: Inspect image - if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true' run: | - docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}" - env: - IMAGE_NAME: ${{ env.IMAGE_NAME }} - TAG: ${{ steps.tag.outputs.tag }} - - # Signal to move-latest that the release tag is live. - - name: Mark release tag pushed - id: mark_release_pushed - if: github.event_name == 'release' - run: echo "pushed=true" >> "$GITHUB_OUTPUT" - - # --------------------------------------------------------------------------- - # Move :latest to point at the release tag the merge job pushed. - # - # :latest is the floating tag that tracks the most recent stable release. - # Only `release: published` events advance it — never main pushes. - # - # We still run an ancestor check against the existing :latest so that a - # backport release on an older branch (e.g. patching v1.1.5 after v1.2.3 - # is out) doesn't drag :latest backwards. The check is the same shape - # as the ancestor check in the merge job for :main: read the OCI - # revision label off the current :latest, look up that commit in git, - # and only advance if our release commit is a strict descendant. - # --------------------------------------------------------------------------- - move-latest: - if: | - github.repository == 'NousResearch/hermes-agent' - && github.event_name == 'release' - && needs.merge.outputs.pushed_release_tag == 'true' - needs: merge - runs-on: ubuntu-latest - timeout-minutes: 10 - concurrency: - group: docker-move-latest - cancel-in-progress: false - steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1000 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - - name: Log in to Docker Hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Decide whether to move :latest - id: latest_check - run: | - set -euo pipefail - image=nousresearch/hermes-agent - - image_json=$( - docker buildx imagetools inspect "${image}:latest" \ - --format '{{ json (index .Image "linux/amd64") }}' \ - 2>/dev/null || true - ) - - if [ -z "${image_json}" ]; then - echo "No existing :latest (or inspect failed) — safe to publish." - echo "push_latest=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - current_sha=$( - printf '%s' "${image_json}" \ - | jq -r '.config.Labels."org.opencontainers.image.revision" // ""' - ) - - if [ -z "${current_sha}" ]; then - echo "Registry :latest has no revision label — safe to publish." - echo "push_latest=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "Registry :latest is at ${current_sha}" - echo "This release is at ${GITHUB_SHA}" - - if [ "${current_sha}" = "${GITHUB_SHA}" ]; then - echo ":latest already points at our SHA — nothing to do." - echo "push_latest=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Make sure we have the :latest commit locally for merge-base. - # Releases can be cut from any branch, so fetch broadly. - if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then - git fetch --no-tags --prune origin \ - "+refs/heads/main:refs/remotes/origin/main" \ - || true - fi - - if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then - echo "Registry :latest points at an unknown commit (${current_sha}); refusing to overwrite." - echo "push_latest=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Our release SHA must be a descendant of the current :latest. - # Backport releases on older branches won't satisfy this and will - # be left alone — :latest stays on the newer release. - if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then - echo "Our release commit is a descendant of :latest — safe to advance." - echo "push_latest=true" >> "$GITHUB_OUTPUT" + if [ "${{ github.event_name }}" = "release" ]; then + docker buildx imagetools inspect "${IMAGE_NAME}:${{ github.event.release.tag_name }}" else - echo "Existing :latest is newer than this release (likely a backport) — leaving it alone." - echo "push_latest=false" >> "$GITHUB_OUTPUT" + docker buildx imagetools inspect "${IMAGE_NAME}:main" fi - - # Retag the already-pushed release manifest as :latest. - - name: Move :latest to this release tag - if: steps.latest_check.outputs.push_latest == 'true' env: - RELEASE_TAG: ${{ needs.merge.outputs.release_tag }} - run: | - set -euo pipefail - image=nousresearch/hermes-agent - docker buildx imagetools create \ - --tag "${image}:latest" \ - "${image}:${RELEASE_TAG}" + IMAGE_NAME: ${{ env.IMAGE_NAME }} diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 2f727e8d2542..3309de78daef 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -3,15 +3,9 @@ name: Supply Chain Audit on: pull_request: types: [opened, synchronize, reopened] - paths: - - '**/*.py' - - '**/*.pth' - - '**/setup.py' - - '**/setup.cfg' - - '**/sitecustomize.py' - - '**/usercustomize.py' - - '**/__init__.pth' - - 'pyproject.toml' + # No paths filter — the jobs must always run so required checks + # report a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). permissions: pull-requests: write @@ -27,8 +21,44 @@ permissions: # advisory-only workflow instead. jobs: + # ── Path filter (shared by both scan and dep-bounds) ─────────────── + changes: + runs-on: ubuntu-latest + outputs: + # True when any file the scanner cares about changed in this PR + scan: ${{ steps.filter.outputs.scan }} + # True when pyproject.toml changed in this PR + deps: ${{ steps.filter.outputs.deps }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - name: Check for relevant file changes + id: filter + run: | + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \ + '*.py' '**/*.py' '*.pth' '**/*.pth' \ + 'setup.py' 'setup.cfg' \ + 'sitecustomize.py' 'usercustomize.py' '__init__.pth' \ + 'pyproject.toml' || true) + if [ -n "$SCAN_FILES" ]; then + echo "scan=true" >> "$GITHUB_OUTPUT" + else + echo "scan=false" >> "$GITHUB_OUTPUT" + fi + DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true) + if [ -n "$DEPS_FILES" ]; then + echo "deps=true" >> "$GITHUB_OUTPUT" + else + echo "deps=false" >> "$GITHUB_OUTPUT" + fi + scan: name: Scan PR for critical supply chain risks + needs: changes + if: needs.changes.outputs.scan == 'true' runs-on: ubuntu-latest steps: - name: Checkout @@ -147,10 +177,24 @@ jobs: echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details." exit 1 + # Gate: reports success when scan was skipped (no relevant files changed). + # This ensures the required check always gets a status. + scan-gate: + name: Scan PR for critical supply chain risks + needs: changes + # always() so the gate still reports SUCCESS even if `changes` fails/is + # skipped — without it, a failed dependency would leave the required + # check unreported (i.e. "pending"), the exact failure mode this fixes. + if: always() && needs.changes.outputs.scan != 'true' + runs-on: ubuntu-latest + steps: + - run: echo "No supply-chain-relevant files changed, skipping scan." + dep-bounds: name: Check PyPI dependency upper bounds + needs: changes + if: needs.changes.outputs.deps == 'true' runs-on: ubuntu-latest - if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -211,3 +255,16 @@ jobs: run: | echo "::error::PyPI dependencies without upper bounds detected. Add