From 8f0476272e334a2bccf9879823e099b7f2763ecf Mon Sep 17 00:00:00 2001 From: chenlinfeng Date: Sat, 9 May 2026 23:32:44 +0800 Subject: [PATCH 1/2] fix(weixin): broaden stale-session detection and strip context_token on rate-limit fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caused iLink errcode=-2 to be misidentified as genuine rate limiting when it was actually a stale/expired session, leading to infinite retries that always fail. Bug 1: _is_stale_session_ret() only recognized errmsg "unknown error" as a session-expiry signal, but iLink also returns empty strings, "session expired", "token expired", etc. with errcode=-2. These were all misclassified as rate-limit errors. Before: (errmsg or "").lower() == "unknown error" After: also match empty errmsg, "unknown error", and any string containing "expire" (covers locale-dependent variants) Bug 2: The rate-limit retry branch in _send_text_chunk() never cleared context_token, so if errcode=-2 was actually a stale session that slipped through _is_stale_session_ret, every retry would carry the same expired token and always get -2 again — a dead loop until the retry budget was exhausted. Fix: strip context_token on the first rate-limit hit (with retried_without_token guard), same as the explicit session-expired branch. This provides a second safety net: even if the errmsg doesn't match any known pattern, the degraded tokenless retry can still succeed. Together these changes ensure that stale iLink sessions are recovered reliably regardless of the exact errmsg wording returned by the server. --- gateway/platforms/weixin.py | 38 +++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 1c20b3f29020b..fda07357db723 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -99,12 +99,25 @@ def _is_stale_session_ret( ret: "Optional[int]", errcode: "Optional[int]", errmsg: "Optional[str]", ) -> bool: - """True when iLink returns ret=-2 / errcode=-2 with 'unknown error', - which is a stale-session signal (same as errcode=-14) rather than - a genuine rate limit.""" + """True when iLink returns ret=-2 / errcode=-2 with a stale-session signal + (same as errcode=-14) rather than a genuine rate limit. + + iLink uses errcode=-2 for both rate limiting and session expiry. + The session-expiry variant typically returns one of: + "unknown error", "session expired", "token expired", or an empty string. + When the errmsg contains "expire" or is empty/unknown, treat it as a + stale session so the caller strips context_token and retries cleanly. + """ if ret != RATE_LIMIT_ERRCODE and errcode != RATE_LIMIT_ERRCODE: return False - return (errmsg or "").lower() == "unknown error" + msg = (errmsg or "").lower().strip() + if not msg: + return True + if msg == "unknown error": + return True + if "expire" in msg: + return True + return False MEDIA_IMAGE = 1 @@ -1634,6 +1647,23 @@ async def _send_text_chunk( ) if attempt >= self._send_chunk_retries: break + # If context_token is present, strip it on the first + # rate-limit hit. Some iLink errcode=-2 responses + # are actually stale-session signals whose errmsg + # didn't match _is_stale_session_ret (e.g. locale- + # dependent messages). Retrying without the token + # degrades gracefully and avoids infinite retries + # with an expired session. + if not retried_without_token and context_token: + retried_without_token = True + context_token = None + self._token_store._cache.pop( + self._token_store._key(self._account_id, chat_id), None + ) + logger.warning( + "[%s] rate-limit hit for %s; stripping context_token as stale-session fallback", + self.name, _safe_id(chat_id), + ) wait = self._send_chunk_retry_delay_seconds * 3 # 3x backoff for rate limit logger.warning( "[%s] rate limited for %s; backing off %.1fs before retry", From db8bfc7a0e147254e48793564603df0689e4337f Mon Sep 17 00:00:00 2001 From: chenlinfeng Date: Sat, 9 May 2026 23:38:42 +0800 Subject: [PATCH 2/2] temp: remove workflows for push workaround --- .github/workflows/contributor-check.yml | 73 ---- .github/workflows/deploy-site.yml | 97 ------ .github/workflows/docker-publish.yml | 407 ----------------------- .github/workflows/docs-site-checks.yml | 48 --- .github/workflows/lint.yml | 201 ----------- .github/workflows/nix-lockfile-fix.yml | 254 -------------- .github/workflows/nix.yml | 117 ------- .github/workflows/osv-scanner.yml | 67 ---- .github/workflows/skills-index.yml | 101 ------ .github/workflows/supply-chain-audit.yml | 139 -------- .github/workflows/tests.yml | 82 ----- .github/workflows/uv-lockfile-check.yml | 119 ------- 12 files changed, 1705 deletions(-) delete mode 100644 .github/workflows/contributor-check.yml delete mode 100644 .github/workflows/deploy-site.yml delete mode 100644 .github/workflows/docker-publish.yml delete mode 100644 .github/workflows/docs-site-checks.yml delete mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/nix-lockfile-fix.yml delete mode 100644 .github/workflows/nix.yml delete mode 100644 .github/workflows/osv-scanner.yml delete mode 100644 .github/workflows/skills-index.yml delete mode 100644 .github/workflows/supply-chain-audit.yml delete mode 100644 .github/workflows/tests.yml delete mode 100644 .github/workflows/uv-lockfile-check.yml diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml deleted file mode 100644 index 3ca4991c615f8..0000000000000 --- a/.github/workflows/contributor-check.yml +++ /dev/null @@ -1,73 +0,0 @@ -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' - -permissions: - contents: read - -jobs: - check-attribution: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 # Full history needed for git log - - - name: Check for unmapped contributor emails - run: | - # Get the merge base between this PR and main - MERGE_BASE=$(git merge-base origin/main HEAD) - - # Find any new author emails in this PR's commits - NEW_EMAILS=$(git log ${MERGE_BASE}..HEAD --format='%ae' --no-merges | sort -u) - - if [ -z "$NEW_EMAILS" ]; then - echo "No new commits to check." - exit 0 - fi - - # Check each email against AUTHOR_MAP in release.py - MISSING="" - while IFS= read -r email; do - # Skip teknium and bot emails - case "$email" in - *teknium*|*noreply@github.com*|*dependabot*|*github-actions*|*anthropic.com*|*cursor.com*) - continue ;; - esac - - # Check if email is in AUTHOR_MAP (either as a key or matches noreply pattern) - if echo "$email" | grep -qP '\+.*@users\.noreply\.github\.com'; then - continue # GitHub noreply emails auto-resolve - fi - - if ! grep -qF "\"${email}\"" scripts/release.py 2>/dev/null; then - AUTHOR=$(git log --author="$email" --format='%an' -1) - MISSING="${MISSING}\n ${email} (${AUTHOR})" - fi - done <<< "$NEW_EMAILS" - - if [ -n "$MISSING" ]; then - echo "" - echo "⚠️ New contributor email(s) not in AUTHOR_MAP:" - echo -e "$MISSING" - echo "" - echo "Please add mappings to scripts/release.py AUTHOR_MAP:" - echo -e "$MISSING" | while read -r line; do - email=$(echo "$line" | sed 's/^ *//' | cut -d' ' -f1) - [ -z "$email" ] && continue - echo " \"${email}\": \"\"," - done - echo "" - echo "To find the GitHub username for an email:" - echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'" - exit 1 - else - echo "✅ All contributor emails are mapped in AUTHOR_MAP." - fi diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml deleted file mode 100644 index 8df74c0509eba..0000000000000 --- a/.github/workflows/deploy-site.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Deploy Site - -on: - release: - types: [published] - push: - branches: [main] - paths: - - 'website/**' - - 'skills/**' - - 'optional-skills/**' - - '.github/workflows/deploy-site.yml' - workflow_dispatch: - -permissions: - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - deploy-vercel: - if: github.event_name == 'release' - runs-on: ubuntu-latest - steps: - - name: Trigger Vercel Deploy - run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}" - - deploy-docs: - if: github.repository == 'NousResearch/hermes-agent' - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: website/package-lock.json - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.11' - - - name: Install PyYAML for skill extraction - run: pip install pyyaml==6.0.2 httpx==0.28.1 - - - name: Extract skill metadata for dashboard - run: python3 website/scripts/extract-skills.py - - - name: Regenerate per-skill docs pages + catalogs - run: python3 website/scripts/generate-skill-docs.py - - - name: Build skills index (if not already present) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if [ ! -f website/static/api/skills-index.json ]; then - python3 scripts/build_skills_index.py || echo "Skills index build failed (non-fatal)" - fi - - - name: Install dependencies - run: npm ci - working-directory: website - - - name: Build Docusaurus - run: npm run build - working-directory: website - - - name: Stage deployment - run: | - mkdir -p _site/docs - cp -r website/build/* _site/docs/ - # llms.txt / llms-full.txt are also published at the site root - # (https://hermes-agent.nousresearch.com/llms.txt) because some - # agents and IDE plugins probe the classic root-level path rather - # than /docs/llms.txt. Same file, two URLs, one source of truth. - if [ -f website/build/llms.txt ]; then - cp website/build/llms.txt _site/llms.txt - fi - if [ -f website/build/llms-full.txt ]; then - cp website/build/llms-full.txt _site/llms-full.txt - fi - - - name: Upload artifact - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 - with: - path: _site - - - name: Deploy to GitHub Pages - id: deploy - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 551e5514d4935..0000000000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,407 +0,0 @@ -name: Docker Build and Publish - -on: - push: - branches: [main] - paths: - - '**/*.py' - - 'pyproject.toml' - - 'uv.lock' - - 'Dockerfile' - - 'docker/**' - - '.github/workflows/docker-publish.yml' - - '.github/actions/hermes-smoke-test/**' - pull_request: - branches: [main] - paths: - - '**/*.py' - - 'pyproject.toml' - - 'uv.lock' - - 'Dockerfile' - - 'docker/**' - - '.github/workflows/docker-publish.yml' - - '.github/actions/hermes-smoke-test/**' - release: - types: [published] - -permissions: - contents: read - -# Concurrency: push/release runs are NEVER cancelled so every merge gets its -# own SHA-tagged image; :latest is guarded separately by the move-latest job. -# 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: - group: docker-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -env: - IMAGE_NAME: nousresearch/hermes-agent - -jobs: - # --------------------------------------------------------------------------- - # Build amd64 natively. This job also runs the smoke tests (basic --help - # and the dashboard subcommand regression guard from #9153), because amd64 - # is the only arch we can `load` into the local daemon on an amd64 runner. - # --------------------------------------------------------------------------- - build-amd64: - # Only run on the upstream repository, not on forks - if: github.repository == 'NousResearch/hermes-agent' - runs-on: ubuntu-latest - timeout-minutes: 45 - outputs: - digest: ${{ steps.push.outputs.digest }} - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - submodules: recursive - - - 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 (amd64, smoke test) - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 - with: - context: . - file: Dockerfile - load: true - platforms: linux/amd64 - tags: ${{ env.IMAGE_NAME }}:test - cache-from: type=gha,scope=docker-amd64 - cache-to: type=gha,mode=max,scope=docker-amd64 - - - name: Smoke test image - uses: ./.github/actions/hermes-smoke-test - with: - image: ${{ env.IMAGE_NAME }}:test - - - name: Log in to Docker Hub - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # 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 `:latest` 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' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 - with: - context: . - file: Dockerfile - platforms: linux/amd64 - labels: | - org.opencontainers.image.revision=${{ 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 - - # Write the digest to a file and upload it as an artifact so the - # merge job can stitch both per-arch digests into a manifest list. - - name: Export digest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - run: | - mkdir -p /tmp/digests - digest="${{ steps.push.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - - name: Upload digest artifact - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: digest-amd64 - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - # --------------------------------------------------------------------------- - # Build arm64 natively on GitHub's free arm64 runner. This replaces the - # previous QEMU-emulated arm64 build, which was ~5-10x slower and shared - # a cache scope with amd64. Matches the amd64 job's shape: build+load, - # smoke test, then on push/release push by digest. - # --------------------------------------------------------------------------- - build-arm64: - if: github.repository == 'NousResearch/hermes-agent' - runs-on: ubuntu-24.04-arm - timeout-minutes: 45 - outputs: - digest: ${{ steps.push.outputs.digest }} - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - submodules: recursive - - - 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) - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 - with: - context: . - file: Dockerfile - load: true - platforms: linux/arm64 - tags: ${{ env.IMAGE_NAME }}:test - cache-from: type=gha,scope=docker-arm64 - cache-to: type=gha,mode=max,scope=docker-arm64 - - - name: Smoke test image - uses: ./.github/actions/hermes-smoke-test - with: - image: ${{ env.IMAGE_NAME }}:test - - - name: Log in to Docker Hub - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Push arm64 by digest - id: push - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 - with: - context: . - file: Dockerfile - platforms: linux/arm64 - labels: | - org.opencontainers.image.revision=${{ 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 - - - name: Export digest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - run: | - mkdir -p /tmp/digests - digest="${{ steps.push.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - - name: Upload digest artifact - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: digest-arm64 - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - # --------------------------------------------------------------------------- - # 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 :sha-. - # On releases it produces :. - # --------------------------------------------------------------------------- - 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_sha_tag: ${{ steps.mark_pushed.outputs.pushed }} - steps: - - name: Download digests - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - path: /tmp/digests - pattern: digest-* - merge-multiple: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # Compute the tag for this run. Main pushes use sha- (so every - # commit gets its own immutable tag); 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=sha-${{ github.sha }}" >> "$GITHUB_OUTPUT" - fi - - - name: Create manifest list and push - working-directory: /tmp/digests - run: | - set -euo pipefail - # Build the arg array from each digest file (filename = the digest - # hex, with no sha256: prefix; empty file content, only the name - # matters). Using an array avoids shellcheck SC2046 and keeps - # every digest a single argv token even under pathological names. - args=() - for digest_file in *; do - args+=("${IMAGE_NAME}@sha256:${digest_file}") - done - docker buildx imagetools create \ - -t "${IMAGE_NAME}:${TAG}" \ - "${args[@]}" - env: - IMAGE_NAME: ${{ env.IMAGE_NAME }} - TAG: ${{ steps.tag.outputs.tag }} - - - name: Inspect image - 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 SHA tag is live. Only on main pushes; - # releases don't trigger move-latest (they use their own release tag). - - name: Mark SHA tag pushed - id: mark_pushed - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: echo "pushed=true" >> "$GITHUB_OUTPUT" - - # --------------------------------------------------------------------------- - # Move :latest to point at the SHA tag the merge job pushed. - # - # The real serialization guarantee comes from the top-level concurrency - # group (`docker-${{ github.ref }}` with `cancel-in-progress: false`), - # which ensures at most one workflow run for this ref executes at a time. - # That means two move-latest steps for the same ref cannot overlap. - # - # This job has its own concurrency group as defense-in-depth: if the - # top-level group is ever loosened, queued move-latests will run serially - # in arrival order, each one running the ancestor check below and either - # advancing :latest or skipping. `cancel-in-progress: false` matches the - # top-level setting — we don't want rapid pushes to cancel a queued - # move-latest, because the ancestor check is the real safety mechanism - # and queueing is cheap (move-latest is a ~30s registry op). - # - # Combined with the ancestor check, this means :latest only ever moves - # forward in git history. - # --------------------------------------------------------------------------- - move-latest: - if: | - github.repository == 'NousResearch/hermes-agent' - && github.event_name == 'push' - && github.ref == 'refs/heads/main' - && needs.merge.outputs.pushed_sha_tag == 'true' - needs: merge - runs-on: ubuntu-latest - timeout-minutes: 10 - concurrency: - group: docker-move-latest-${{ github.ref }} - cancel-in-progress: false - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - 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@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # Read the git revision label off the current :latest manifest, then - # use `git merge-base --is-ancestor` to check whether our commit is a - # descendant of it. If :latest doesn't exist yet, or its label is - # missing, we treat that as "safe to publish". If another run already - # advanced :latest past us (or diverged), we skip and leave it alone. - - name: Decide whether to move :latest - id: latest_check - run: | - set -euo pipefail - image=nousresearch/hermes-agent - - # Pull the JSON for the linux/amd64 sub-manifest's config and extract - # the OCI revision label with jq — Go template field access can't - # handle dots in map keys, so using json+jq is the robust route. - 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 run 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. - 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 SHA must be a descendant of the current :latest to be safe. - if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then - echo "Our commit is a descendant of :latest — safe to advance." - echo "push_latest=true" >> "$GITHUB_OUTPUT" - else - echo "Another run advanced :latest past us (or diverged) — leaving it alone." - echo "push_latest=false" >> "$GITHUB_OUTPUT" - fi - - # Retag the already-pushed SHA manifest as :latest. This is a registry- - # side operation — no rebuild, no layer re-push — so it's quick and - # atomic per-tag. The ancestor check above plus the cancel-in-progress - # concurrency on this job together guarantee we only ever move :latest - # forward in git history. - - name: Move :latest to this SHA - if: steps.latest_check.outputs.push_latest == 'true' - run: | - set -euo pipefail - image=nousresearch/hermes-agent - docker buildx imagetools create \ - --tag "${image}:latest" \ - "${image}:sha-${GITHUB_SHA}" diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml deleted file mode 100644 index 80fe9ea9d49f9..0000000000000 --- a/.github/workflows/docs-site-checks.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Docs Site Checks - -on: - pull_request: - paths: - - 'website/**' - - '.github/workflows/docs-site-checks.yml' - workflow_dispatch: - -permissions: - contents: read - -jobs: - docs-site-checks: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: website/package-lock.json - - - name: Install website dependencies - run: npm ci - working-directory: website - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.11' - - - name: Install ascii-guard - run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3 - - - name: Extract skill metadata for dashboard - run: python3 website/scripts/extract-skills.py - - - name: Regenerate per-skill docs pages + catalogs - run: python3 website/scripts/generate-skill-docs.py - - - name: Lint docs diagrams - run: npm run lint:diagrams - working-directory: website - - - name: Build Docusaurus - run: npm run build - working-directory: website diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index a2a7b2e8d36f0..0000000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,201 +0,0 @@ -name: Lint (ruff + ty) - -# Two things here: -# 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch. -# Posts a Markdown summary and a PR comment. Exit zero always. -# 2. Blocking ``ruff check .`` — enforces the explicit rules in -# ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge. -# Separate job so the advisory diff still runs and posts even when -# enforcement fails. - -on: - push: - branches: [main] - paths-ignore: - - "**/*.md" - - "docs/**" - - "website/**" - pull_request: - branches: [main] - paths-ignore: - - "**/*.md" - - "docs/**" - - "website/**" - -permissions: - contents: read - pull-requests: write # needed to post/update PR comments - -concurrency: - group: lint-${{ github.ref }} - cancel-in-progress: true - -jobs: - lint-diff: - name: ruff + ty diff - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 # need full history for merge-base + worktree - - - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - - - name: Install ruff + ty - run: | - uv tool install ruff - uv tool install ty - - - name: Determine base ref - id: base - run: | - # For PRs, diff against the merge base with the target branch. - # For pushes to main, diff against the previous commit on main. - if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD) - BASE_REF="origin/${{ github.base_ref }}" - else - BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD) - BASE_REF="HEAD~1" - fi - echo "sha=${BASE_SHA}" >> "$GITHUB_OUTPUT" - echo "ref=${BASE_REF}" >> "$GITHUB_OUTPUT" - echo "Base SHA: ${BASE_SHA}" - echo "Base ref: ${BASE_REF}" - - - name: Run ruff + ty on HEAD - run: | - mkdir -p .lint-reports/head - ruff check --output-format json --exit-zero \ - > .lint-reports/head/ruff.json || true - ty check --output-format gitlab --exit-zero \ - > .lint-reports/head/ty.json || true - echo "HEAD ruff: $(wc -c < .lint-reports/head/ruff.json) bytes" - echo "HEAD ty: $(wc -c < .lint-reports/head/ty.json) bytes" - - - name: Run ruff + ty on base (via git worktree) - run: | - mkdir -p .lint-reports/base - # Use a worktree so we don't clobber the main checkout. If the basex - # SHA is identical to HEAD (e.g. first commit), skip and leave the - # base reports empty — the diff script handles missing files. - HEAD_SHA=$(git rev-parse HEAD) - BASE_SHA="${{ steps.base.outputs.sha }}" - if [ "$BASE_SHA" = "$HEAD_SHA" ]; then - echo "Base SHA == HEAD SHA, skipping base scan." - echo '[]' > .lint-reports/base/ruff.json - echo '[]' > .lint-reports/base/ty.json - else - git worktree add --detach /tmp/lint-base "$BASE_SHA" - ( - cd /tmp/lint-base - ruff check --output-format json --exit-zero \ - > "$GITHUB_WORKSPACE/.lint-reports/base/ruff.json" || true - ty check --output-format gitlab --exit-zero \ - > "$GITHUB_WORKSPACE/.lint-reports/base/ty.json" || true - ) - git worktree remove --force /tmp/lint-base - fi - echo "base ruff: $(wc -c < .lint-reports/base/ruff.json) bytes" - echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes" - - - name: Generate diff summary - run: | - python scripts/lint_diff.py \ - --base-ruff .lint-reports/base/ruff.json \ - --head-ruff .lint-reports/head/ruff.json \ - --base-ty .lint-reports/base/ty.json \ - --head-ty .lint-reports/head/ty.json \ - --base-ref "${{ steps.base.outputs.ref }}" \ - --head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \ - --output .lint-reports/summary.md - cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY" - - - name: Upload reports as artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: lint-reports - path: .lint-reports/ - retention-days: 14 - - - name: Post / update PR comment - if: github.event_name == 'pull_request' - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 - with: - script: | - const fs = require('fs'); - const body = fs.readFileSync('.lint-reports/summary.md', 'utf8'); - const marker = ''; - const fullBody = marker + '\n' + body; - - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - }); - const existing = comments.find(c => c.body && c.body.includes(marker)); - if (existing) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: existing.id, - body: fullBody, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: fullBody, - }); - } - - - ruff-blocking: - # Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently - # PLW1514 (unspecified-encoding) — catches bare ``open()`` / - # ``read_text()`` / ``write_text()`` calls that default to locale - # encoding on Windows. Failure here blocks merge; the advisory - # ``lint-diff`` job above runs independently so reviewers still get - # the diff comment even when enforcement fails. - name: ruff enforcement (blocking) - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - - - name: Install ruff - run: uv tool install ruff - - - name: ruff check . - # No --exit-zero, no || true. Exit code propagates to the job, - # which propagates to the required-check gate. - run: | - ruff check . - - windows-footguns: - # Static guardrails on Windows-unsafe Python primitives — os.kill(pid, 0), - # os.killpg, os.setsid, signal.SIGKILL without getattr fallback, - # shebang scripts via subprocess, bare open() without encoding=, etc. - # See scripts/check-windows-footguns.py for the full rule list. - name: Windows footguns (blocking) - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Set up Python - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5 - with: - python-version: "3.11" - - - name: Run footgun checker - run: python scripts/check-windows-footguns.py --all diff --git a/.github/workflows/nix-lockfile-fix.yml b/.github/workflows/nix-lockfile-fix.yml deleted file mode 100644 index b5e02c341bd54..0000000000000 --- a/.github/workflows/nix-lockfile-fix.yml +++ /dev/null @@ -1,254 +0,0 @@ -name: Nix Lockfile Fix - -on: - push: - branches: [main] - paths: - - 'ui-tui/package-lock.json' - - 'ui-tui/package.json' - - 'web/package-lock.json' - - 'web/package.json' - workflow_dispatch: - inputs: - pr_number: - description: 'PR number to fix (leave empty to run on the selected branch)' - required: false - type: string - issue_comment: - types: [edited] - -permissions: - contents: write - pull-requests: write - -concurrency: - group: nix-lockfile-fix-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }} - cancel-in-progress: false - -jobs: - # ── Auto-fix on main ─────────────────────────────────────────────── - # Fires when a push to main touches package.json or package-lock.json - # in ui-tui/ or web/. Runs fix-lockfiles and pushes the hash - # update commit directly to main so Nix builds never stay broken. - # - # Safety invariants: - # 1. The fix commit only touches nix/*.nix files, which are NOT in - # the paths filter above, so this cannot re-trigger itself. - # 2. An explicit file-whitelist check before commit aborts if - # fix-lockfiles ever modifies unexpected files. - # 3. Job-level concurrency with cancel-in-progress: true ensures - # back-to-back pushes collapse to the newest; ref: main checkout - # always operates on the latest branch state. - # 4. Uses a GitHub App token (not GITHUB_TOKEN) so the fix commit - # triggers downstream nix.yml verification. - auto-fix-main: - if: github.event_name == 'push' - runs-on: ubuntu-latest - timeout-minutes: 25 - concurrency: - group: auto-fix-main - cancel-in-progress: true - steps: - - name: Generate GitHub App token - id: app-token - uses: actions/create-github-app-token@7bfa3a4717ef143a604ee0a99d859b8886a96d00 # v1.9.3 - with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - ref: main - token: ${{ steps.app-token.outputs.token }} - - - uses: ./.github/actions/nix-setup - with: - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - - - name: Apply lockfile hashes - id: apply - run: nix run .#fix-lockfiles -- --apply - - - name: Commit & push - if: steps.apply.outputs.changed == 'true' - shell: bash - run: | - set -euo pipefail - - # Ensure only nix files were modified — prevents accidental - # self-triggering if fix-lockfiles ever touches package files. - unexpected="$(git diff --name-only | grep -Ev '^nix/(tui|web)\.nix$' || true)" - if [ -n "$unexpected" ]; then - echo "::error::Unexpected modified files: $unexpected" - exit 1 - fi - - # Record the base SHA before committing — used to detect package - # file changes if we need to rebase after a non-fast-forward push. - BASE_SHA="$(git rev-parse HEAD)" - - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add nix/tui.nix nix/web.nix - git commit -m "fix(nix): auto-refresh npm lockfile hashes" \ - -m "Source: $GITHUB_SHA" \ - -m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" - - # Retry push with rebase in case main advanced with an unrelated - # commit during the nix build. Without this, a non-fast-forward - # rejection silently loses the fix. If package files changed during - # the rebase, abort — a fresh auto-fix run will handle the new state. - for attempt in 1 2 3; do - if git push origin HEAD:main; then - exit 0 - fi - echo "::warning::Push attempt $attempt failed (non-fast-forward?), rebasing…" - git fetch origin main - - # If package files changed between our base and the new main, - # our computed hashes are stale. Abort and let the next triggered - # run recompute from the correct package-lock state. - pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \ - 'ui-tui/package-lock.json' 'ui-tui/package.json' \ - 'web/package-lock.json' 'web/package.json' || true)" - if [ -n "$pkg_changed" ]; then - echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute" - exit 0 - fi - - git rebase origin/main - done - echo "::error::Failed to push after 3 rebase attempts" - exit 1 - - # ── PR fix (manual / checkbox) ───────────────────────────────────── - # Existing behavior: run on manual dispatch OR when a task-list - # checkbox in the sticky lockfile-check comment flips from [ ] to [x]. - fix: - if: | - github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' - && github.event.issue.pull_request != null - && contains(github.event.comment.body, '[x] **Apply lockfile fix**') - && !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**')) - runs-on: ubuntu-latest - timeout-minutes: 25 - steps: - - name: Authorize & resolve PR - id: resolve - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 - with: - script: | - // 1. Verify the actor has write access — applies to both checkbox - // clicks and manual dispatch. - const { data: perm } = - await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: context.actor, - }); - if (!['admin', 'write', 'maintain'].includes(perm.permission)) { - core.setFailed( - `${context.actor} lacks write access (has: ${perm.permission})` - ); - return; - } - - // 2. Resolve which ref to check out. - let prNumber = ''; - if (context.eventName === 'issue_comment') { - prNumber = String(context.payload.issue.number); - } else if (context.eventName === 'workflow_dispatch') { - prNumber = context.payload.inputs.pr_number || ''; - } - - if (!prNumber) { - core.setOutput('ref', context.ref.replace(/^refs\/heads\//, '')); - core.setOutput('repo', context.repo.repo); - core.setOutput('owner', context.repo.owner); - core.setOutput('pr', ''); - return; - } - - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: Number(prNumber), - }); - core.setOutput('ref', pr.head.ref); - core.setOutput('repo', pr.head.repo.name); - core.setOutput('owner', pr.head.repo.owner.login); - core.setOutput('pr', String(pr.number)); - - # Wipe the sticky lockfile-check comment to a "running" state as soon - # as the job is authorized, so the user sees their click was picked up - # before the ~minute of nix build work. - - name: Mark sticky as running - if: steps.resolve.outputs.pr != '' - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - number: ${{ steps.resolve.outputs.pr }} - message: | - ### 🔄 Applying lockfile fix… - - Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }} - ref: ${{ steps.resolve.outputs.ref }} - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - - - uses: ./.github/actions/nix-setup - with: - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - - - name: Apply lockfile hashes - id: apply - run: nix run .#fix-lockfiles - - - name: Commit & push - if: steps.apply.outputs.changed == 'true' - shell: bash - run: | - set -euo pipefail - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add nix/tui.nix nix/web.nix - git commit -m "fix(nix): refresh npm lockfile hashes" - git push - - - name: Update sticky (applied) - if: steps.apply.outputs.changed == 'true' && steps.resolve.outputs.pr != '' - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - number: ${{ steps.resolve.outputs.pr }} - message: | - ### ✅ Lockfile fix applied - - Pushed a commit refreshing the npm lockfile hashes — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - - name: Update sticky (already current) - if: steps.apply.outputs.changed == 'false' && steps.resolve.outputs.pr != '' - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - number: ${{ steps.resolve.outputs.pr }} - message: | - ### ✅ Lockfile hashes already current - - Nothing to commit — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). - - - name: Update sticky (failed) - if: failure() && steps.resolve.outputs.pr != '' - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - number: ${{ steps.resolve.outputs.pr }} - message: | - ### ❌ Lockfile fix failed - - See the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for logs. diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml deleted file mode 100644 index 9a8f45a7c190c..0000000000000 --- a/.github/workflows/nix.yml +++ /dev/null @@ -1,117 +0,0 @@ -name: Nix - -on: - push: - branches: [main] - pull_request: - -permissions: - contents: read - pull-requests: write - -concurrency: - group: nix-${{ github.ref }} - cancel-in-progress: true - -jobs: - nix: - strategy: - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: ./.github/actions/nix-setup - with: - cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }} - - - name: Resolve head SHA - if: github.event_name == 'pull_request' - id: sha - shell: bash - run: | - FULL="${{ github.event.pull_request.head.sha || github.sha }}" - echo "full=$FULL" >> "$GITHUB_OUTPUT" - echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT" - - - name: Check flake - id: flake - if: runner.os == 'Linux' - continue-on-error: true - run: nix flake check --print-build-logs - - - name: Build package - id: build - if: runner.os == 'Linux' - continue-on-error: true - run: nix build --print-build-logs - - # When the real Nix build fails, run a targeted diagnostic to see if - # the failure is specifically a stale npm lockfile hash in one of the - # known npm subpackages (tui / web). This avoids surfacing a generic - # "build failed" message when the fix is a single known command. - - name: Diagnose npm lockfile hashes - id: hash_check - if: (steps.flake.outcome == 'failure' || steps.build.outcome == 'failure') && runner.os == 'Linux' - continue-on-error: true - env: - LINK_SHA: ${{ steps.sha.outputs.full }} - run: nix run .#fix-lockfiles -- --check - - # If fix-lockfiles itself crashes (infrastructure blip, cache throttle, - # etc.) it won't set stale=true/false. Treat that as a distinct failure - # mode rather than silently ignoring it. - - name: Fail if hash check crashed without reporting - if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false' - run: | - echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure" - exit 1 - - - name: Post sticky PR comment (stale hashes) - if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request' - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - message: | - ### ⚠️ npm lockfile hash out of date - - Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time). - - The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`: - - ${{ steps.hash_check.outputs.report }} - - #### Apply the fix - - - [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch - - Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`) - - Or locally: `nix run .#fix-lockfiles` and commit the diff - - # Clear the sticky comment when either the build passed outright (no - # hash check needed) or the hash check explicitly returned stale=false - # (build failed for a non-hash reason). - - name: Clear sticky PR comment (resolved) - if: | - github.event_name == 'pull_request' && - runner.os == 'Linux' && - (steps.hash_check.outputs.stale == 'false' || - (steps.flake.outcome == 'success' && steps.build.outcome == 'success')) - uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1 - with: - header: nix-lockfile-check - delete: true - - - name: Final fail if build or flake failed - if: steps.flake.outcome == 'failure' || steps.build.outcome == 'failure' - run: | - if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then - echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles" - else - echo "::error::Nix build/flake check failed. See logs above." - fi - exit 1 - - - name: Evaluate flake (macOS) - if: runner.os == 'macOS' - run: nix flake show --json > /dev/null diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml deleted file mode 100644 index db8c3d75ce9ba..0000000000000 --- a/.github/workflows/osv-scanner.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: OSV-Scanner - -# Scans lockfiles (uv.lock, package-lock.json) against the OSV vulnerability -# database. Runs on every PR that touches a lockfile and on a weekly schedule -# against main. -# -# This is detection-only — OSV-Scanner does NOT open PRs or modify pins. -# It reports known CVEs in currently-pinned dependency versions so we can -# decide when and how to patch on our own schedule. Our pinning strategy -# (full SHA / exact version) is preserved; only the notification signal -# is added. -# -# Complements the existing supply-chain-audit.yml workflow (which scans -# for malicious code patterns in PR diffs) by covering the orthogonal -# "currently-pinned dep became known-vulnerable" case. -# -# Uses Google's officially-recommended reusable workflow, pinned by SHA. -# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner). -# fail-on-vuln is disabled so the job does not block merges on pre-existing -# vulnerabilities in pinned deps that we may need to patch deliberately. - -on: - pull_request: - branches: [main] - paths: - - 'uv.lock' - - 'pyproject.toml' - - 'package.json' - - 'package-lock.json' - - 'ui-tui/package.json' - - 'ui-tui/package-lock.json' - - 'website/package.json' - - 'website/package-lock.json' - - '.github/workflows/osv-scanner.yml' - push: - branches: [main] - paths: - - 'uv.lock' - - 'pyproject.toml' - - 'package.json' - - 'package-lock.json' - - 'ui-tui/package-lock.json' - - 'website/package-lock.json' - schedule: - # Weekly scan against main — catches CVEs published after merge for - # deps that haven't changed since. - - cron: '0 9 * * 1' - workflow_dispatch: - -permissions: - # Required by the reusable workflow to upload SARIF to the Security tab. - actions: read - contents: read - security-events: write - -jobs: - scan: - name: Scan lockfiles - uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5 # v2.3.5 - with: - # Scan explicit lockfiles rather than recursing, so we only look at - # the three sources of truth and skip vendored / test / worktree dirs. - scan-args: |- - --lockfile=uv.lock - --lockfile=ui-tui/package-lock.json - --lockfile=website/package-lock.json - fail-on-vuln: false diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml deleted file mode 100644 index 8beda195c6644..0000000000000 --- a/.github/workflows/skills-index.yml +++ /dev/null @@ -1,101 +0,0 @@ -name: Build Skills Index - -on: - schedule: - # Run twice daily: 6 AM and 6 PM UTC - - cron: '0 6,18 * * *' - workflow_dispatch: # Manual trigger - push: - branches: [main] - paths: - - 'scripts/build_skills_index.py' - - '.github/workflows/skills-index.yml' - -permissions: - contents: read - -jobs: - build-index: - # Only run on the upstream repository, not on forks - if: github.repository == 'NousResearch/hermes-agent' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: pip install httpx==0.28.1 pyyaml==6.0.2 - - - name: Build skills index - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: python scripts/build_skills_index.py - - - name: Upload index artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: skills-index - path: website/static/api/skills-index.json - retention-days: 7 - - deploy-with-index: - needs: build-index - runs-on: ubuntu-latest - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - # Only deploy on schedule or manual trigger (not on every push to the script) - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: skills-index - path: website/static/api/ - - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: website/package-lock.json - - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: '3.11' - - - name: Install PyYAML for skill extraction - run: pip install pyyaml==6.0.2 - - - name: Extract skill metadata for dashboard - run: python3 website/scripts/extract-skills.py - - - name: Install dependencies - run: npm ci - working-directory: website - - - name: Build Docusaurus - run: npm run build - working-directory: website - - - name: Stage deployment - run: | - mkdir -p _site/docs - cp -r landingpage/* _site/ - cp -r website/build/* _site/docs/ - echo "hermes-agent.nousresearch.com" > _site/CNAME - - - name: Upload artifact - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 - with: - path: _site - - - name: Deploy to GitHub Pages - id: deploy - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml deleted file mode 100644 index 417e7b21f843d..0000000000000 --- a/.github/workflows/supply-chain-audit.yml +++ /dev/null @@ -1,139 +0,0 @@ -name: Supply Chain Audit - -on: - pull_request: - types: [opened, synchronize, reopened] - paths: - - '**/*.py' - - '**/*.pth' - - '**/setup.py' - - '**/setup.cfg' - - '**/sitecustomize.py' - - '**/usercustomize.py' - - '**/__init__.pth' - -permissions: - pull-requests: write - contents: read - -# Narrow, high-signal scanner. Only fires on critical indicators of supply -# chain attacks (e.g. the litellm-style payloads). Low-signal heuristics -# (plain base64, plain exec/eval, dependency/Dockerfile/workflow edits, -# Actions version unpinning, outbound POST/PUT) were intentionally -# removed — they fired on nearly every PR and trained reviewers to ignore -# the scanner. Keep this file's checks ruthlessly narrow: if you find -# yourself adding WARNING-tier patterns here again, make a separate -# advisory-only workflow instead. - -jobs: - scan: - name: Scan PR for critical supply chain risks - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 0 - - - name: Scan diff for critical patterns - id: scan - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - BASE="${{ github.event.pull_request.base.sha }}" - HEAD="${{ github.event.pull_request.head.sha }}" - - # Added lines only, excluding lockfiles. - DIFF=$(git diff "$BASE".."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true) - - FINDINGS="" - - # --- .pth files (auto-execute on Python startup) --- - # The exact mechanism used in the litellm supply chain attack: - # https://github.com/BerriAI/litellm/issues/24512 - PTH_FILES=$(git diff --name-only "$BASE".."$HEAD" | grep '\.pth$' || true) - if [ -n "$PTH_FILES" ]; then - FINDINGS="${FINDINGS} - ### 🚨 CRITICAL: .pth file added or modified - Python \`.pth\` files in \`site-packages/\` execute automatically when the interpreter starts — no import required. - - **Files:** - \`\`\` - ${PTH_FILES} - \`\`\` - " - fi - - # --- base64 decode + exec/eval on the same line (the litellm attack pattern) --- - B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true) - if [ -n "$B64_EXEC_HITS" ]; then - FINDINGS="${FINDINGS} - ### 🚨 CRITICAL: base64 decode + exec/eval combo - Base64-decoded strings passed directly to exec/eval — the signature of hidden credential-stealing payloads. - - **Matches:** - \`\`\` - ${B64_EXEC_HITS} - \`\`\` - " - fi - - # --- subprocess with encoded/obfuscated command argument --- - PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true) - if [ -n "$PROC_HITS" ]; then - FINDINGS="${FINDINGS} - ### 🚨 CRITICAL: subprocess with encoded/obfuscated command - Subprocess calls whose command strings are base64- or hex-encoded are a strong indicator of payload execution. - - **Matches:** - \`\`\` - ${PROC_HITS} - \`\`\` - " - fi - - # --- Install-hook files (setup.py/sitecustomize/usercustomize/__init__.pth) --- - # These execute during pip install or interpreter startup. - SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(^|/)(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true) - if [ -n "$SETUP_HITS" ]; then - FINDINGS="${FINDINGS} - ### 🚨 CRITICAL: Install-hook file added or modified - These files can execute code during package installation or interpreter startup. - - **Files:** - \`\`\` - ${SETUP_HITS} - \`\`\` - " - fi - - if [ -n "$FINDINGS" ]; then - echo "found=true" >> "$GITHUB_OUTPUT" - echo "$FINDINGS" > /tmp/findings.md - else - echo "found=false" >> "$GITHUB_OUTPUT" - fi - - - name: Post critical finding comment - if: steps.scan.outputs.found == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - BODY="## 🚨 CRITICAL Supply Chain Risk Detected - - This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. - - $(cat /tmp/findings.md) - - --- - *Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*" - - gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)" - - - name: Fail on critical findings - if: steps.scan.outputs.found == 'true' - run: | - echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details." - exit 1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index a92afdfa40dd9..0000000000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Tests - -on: - push: - branches: [main] - paths-ignore: - - '**/*.md' - - 'docs/**' - pull_request: - branches: [main] - paths-ignore: - - '**/*.md' - - 'docs/**' - -permissions: - contents: read - -# Cancel in-progress runs for the same PR/branch -concurrency: - group: tests-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y ripgrep - - - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - - - name: Set up Python 3.11 - run: uv python install 3.11 - - - name: Install dependencies - run: | - uv venv .venv --python 3.11 - source .venv/bin/activate - uv pip install -e ".[all,dev]" - - - name: Run tests - run: | - source .venv/bin/activate - python -m pytest tests/ -q --ignore=tests/integration --ignore=tests/e2e --tb=short -n auto - env: - # Ensure tests don't accidentally call real APIs - OPENROUTER_API_KEY: "" - OPENAI_API_KEY: "" - NOUS_API_KEY: "" - - e2e: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - - - name: Set up Python 3.11 - run: uv python install 3.11 - - - name: Install dependencies - run: | - uv venv .venv --python 3.11 - source .venv/bin/activate - uv pip install -e ".[all,dev]" - - - name: Run e2e tests - run: | - source .venv/bin/activate - python -m pytest tests/e2e/ -v --tb=short - env: - OPENROUTER_API_KEY: "" - OPENAI_API_KEY: "" - NOUS_API_KEY: "" diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml deleted file mode 100644 index 190a162533baf..0000000000000 --- a/.github/workflows/uv-lockfile-check.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: uv.lock check - -# Verify uv.lock is in sync with pyproject.toml. Blocking check — PRs -# that modify pyproject.toml without regenerating uv.lock (or vice versa) -# must not merge, because the Docker build's `uv sync --frozen` step will -# fail on a stale lockfile and we'd rather catch it here than in the -# docker-publish workflow on main. -# -# ───────────────────────────────────────────────────────────────────────── -# IMPORTANT: this check runs against the MERGED state, not just your branch -# ───────────────────────────────────────────────────────────────────────── -# -# For `pull_request` events, GitHub checks out `refs/pull//merge` by -# default — a synthetic commit that merges your PR branch into the CURRENT -# state of `main`. That means the pyproject.toml evaluated here is -# `main's pyproject.toml + your PR's changes to pyproject.toml`, not just -# what's on your branch. -# -# Failure mode this creates: if `main` has advanced since you branched -# (e.g. someone merged a PR that added a dep to pyproject.toml + its -# corresponding uv.lock entries), your branch's uv.lock is missing those -# new entries. `uv lock --check` resolves against the merged pyproject -# and sees a lockfile that doesn't cover all the current deps → fails -# with "The lockfile at uv.lock needs to be updated." -# -# This can be confusing: `uv lock --check` passes locally (your branch -# is internally consistent) but fails in CI (merged state isn't). -# -# Fix is to sync your branch with main and regenerate the lockfile: -# -# git fetch origin main -# git rebase origin/main # or merge, whatever the repo prefers -# uv lock # regenerates uv.lock against new pyproject.toml -# git add uv.lock -# git commit -m "chore: refresh uv.lock after rebase onto main" -# git push --force-with-lease # if you rebased -# -# If you also changed pyproject.toml in your PR, `uv lock` handles that -# at the same time — one regeneration covers both your changes and the -# drift from main. -# -# This is the correct behavior! The check is protecting main's Docker -# build: a post-merge build would see the same merged state and fail -# the same way. Better to catch it here than after merge. - -on: - push: - branches: [main] - paths: - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/uv-lockfile-check.yml' - pull_request: - branches: [main] - paths: - - 'pyproject.toml' - - 'uv.lock' - - '.github/workflows/uv-lockfile-check.yml' - -permissions: - contents: read - -concurrency: - group: uv-lockfile-check-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - check: - name: uv lock --check - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install uv - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - - # `uv lock --check` re-resolves the project from pyproject.toml and - # compares the result to uv.lock, exiting non-zero if they disagree. - # No network writes, no file modifications. - # - # On PRs this runs against the merge commit (see comment at the top - # of this file) — failures often mean "your branch is behind main, - # rebase and regenerate uv.lock." - - name: Verify uv.lock is up-to-date - run: | - if ! uv lock --check; then - cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" - ## ❌ uv.lock is out of sync with pyproject.toml - - **If this is a PR:** this check runs against the merged state - (your branch + current `main`), not just your branch. If - `uv lock --check` passes locally, your branch is likely behind - `main` — recent changes to `pyproject.toml` on `main` aren't - reflected in your branch's `uv.lock` yet. - - To fix, sync with main and regenerate the lockfile: - - ```bash - git fetch origin main - git rebase origin/main # or `git merge origin/main` - uv lock # regenerate against new pyproject.toml - git add uv.lock - git commit -m "chore: refresh uv.lock after syncing with main" - git push --force-with-lease # drop --force-with-lease if you merged - ``` - - **If you only changed pyproject.toml:** run `uv lock` locally - and commit the result. - - This check is blocking because the Docker image build uses - `uv sync --frozen --extra all`, which rejects stale lockfiles - — catching it here avoids a ~15 min failed docker-publish run - on `main` post-merge. - EOF - echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first." - exit 1 - fi