Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions .github/actions/detect-changes/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ description: >-
the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch
events and fail open (everything "true") when the diff cannot be computed.

inputs:
github-token:
description: Token for the GitHub API (gh CLI). Pass secrets.AUTOFIX_BOT_PAT from the calling workflow.
required: false
default: ${{ github.token }}

outputs:
python:
description: Run Python tests / ruff / ty / windows-footguns.
Expand Down Expand Up @@ -41,7 +47,7 @@ runs:
id: classify
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ inputs.github-token }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
Expand All @@ -57,10 +63,26 @@ runs:
# event payload instead of the "current PR files" endpoint. The SHAs
# are frozen at trigger time, so the file list is deterministic even
# if the PR receives a new push between trigger and detect.
CHANGED="$(gh api \
--paginate \
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
--jq '.files[].filename' || true)"
#
# Retried: a rate-limit blip or eventual-consistency 404 on a
# freshly-pushed HEAD would otherwise silently fall open (all lanes
# run — safe, but wasteful and it masks the API failure).
CHANGED=""
for i in 1 2 3; do
if CHANGED="$(gh api \
--paginate \
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
--jq '.files[].filename')"; then
break
fi
if [ "$i" = 3 ]; then
echo "::warning::compare API failed after 3 attempts — failing open (all lanes run)"
CHANGED=""
break
fi
echo "::warning::compare API failed (attempt $i); retrying in 10s"
sleep 10
done
fi

echo "Changed files:"
Expand Down
26 changes: 23 additions & 3 deletions .github/actions/retry/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ description: >-
Run a shell command, retrying on non-zero exit. For dependency installs
(npm ci, uv sync) whose only failures are transient network/toolchain
flakes — a node-gyp header fetch, a registry blip — so CI self-heals
instead of needing a manual re-run.
instead of needing a manual re-run. Can also capture stdout as a step
output for commands whose result must be consumed by later steps.

inputs:
command:
Expand All @@ -19,10 +20,16 @@ inputs:
description: Directory to run in.
default: "."

outputs:
stdout:
description: Captured stdout from the successful attempt (empty if not needed).
value: ${{ steps.retry.outputs.stdout }}

runs:
using: composite
steps:
- shell: bash
- id: retry
shell: bash
working-directory: ${{ inputs.working-directory }}
# command goes through env, never interpolated into the script body, so
# a command with quotes/specials can't break or inject into the runner.
Expand All @@ -32,12 +39,25 @@ runs:
_DELAY: ${{ inputs.delay }}
run: |
set -uo pipefail
_OUTFILE="$(mktemp)"
trap 'rm -f "$_OUTFILE"' EXIT
n=0
while :; do
n=$((n + 1))
echo "::group::attempt $n/$_ATTEMPTS: $_CMD"
if bash -c "$_CMD"; then
# Run the command, capturing stdout to a temp file while still
# streaming to the log. We redirect first, then tee the file to
# stdout — this avoids pipefail + tee exit-code interactions that
# can cause the if-branch to be skipped under set -e.
if bash -c "$_CMD" > "$_OUTFILE"; then
cat "$_OUTFILE"
echo "::endgroup::"
# Preserve newlines in the output via heredoc delimiter.
{
echo 'stdout<<__RETRY_STDOUT_EOF__'
cat "$_OUTFILE"
echo '__RETRY_STDOUT_EOF__'
} >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::endgroup::"
Expand Down
20 changes: 19 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
detect:
name: Detect affected areas
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
python: ${{ steps.classify.outputs.python }}
frontend: ${{ steps.classify.outputs.frontend }}
Expand All @@ -51,6 +52,8 @@ jobs:
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
with:
github-token: ${{ secrets.AUTOFIX_BOT_PAT }}

# ─────────────────────────────────────────────────────────────────────
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
Expand All @@ -63,6 +66,7 @@ jobs:
uses: ./.github/workflows/tests.yml
with:
slice_count: 8
secrets: inherit

lint:
name: Python lints
Expand All @@ -72,47 +76,55 @@ jobs:
with:
event_name: ${{ needs.detect.outputs.event_name }}
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
secrets: inherit

js-tests:
name: JS & TS checks
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/js-tests.yml
secrets: inherit

docs-site:
name: Docs Site
needs: detect
if: needs.detect.outputs.site == 'true'
uses: ./.github/workflows/docs-site-checks.yml
secrets: inherit

history-check:
name: Deny unrelated histories
needs: detect
if: needs.detect.outputs.event_name == 'pull_request'
uses: ./.github/workflows/history-check.yml
secrets: inherit

contributor-check:
name: Check contributors
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/contributor-check.yml
secrets: inherit

uv-lockfile:
name: Check uv.lock
needs: detect
uses: ./.github/workflows/uv-lockfile-check.yml
secrets: inherit

lockfile-diff:
name: package-lock.json diff
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true'
uses: ./.github/workflows/lockfile-diff.yml
secrets: inherit

docker-lint:
name: Lint Docker scripts
needs: detect
if: needs.detect.outputs.docker_meta == 'true'
uses: ./.github/workflows/docker-lint.yml
secrets: inherit

docker:
name: Build&Test Docker image
Expand All @@ -131,10 +143,12 @@ jobs:
scan: ${{ needs.detect.outputs.scan == 'true' }}
deps: ${{ needs.detect.outputs.deps == 'true' }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
secrets: inherit

osv-scanner:
name: OSV scan
uses: ./.github/workflows/osv-scanner.yml
secrets: inherit

# ─────────────────────────────────────────────────────────────────────
# Gate: runs after everything. ``if: always()`` ensures it reports a
Expand All @@ -161,6 +175,7 @@ jobs:
# - docker
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Evaluate job results
env:
Expand Down Expand Up @@ -191,6 +206,7 @@ jobs:
needs: [all-checks-pass, docker]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand All @@ -208,7 +224,7 @@ jobs:

- name: Collect timings and generate report
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
run: |
python3 scripts/ci/timings_report.py \
--baseline ci-timings-baseline.json \
Expand All @@ -217,6 +233,8 @@ jobs:
--summary-out ci-timings-summary.md

- name: Upload HTML report
# Advisory report — artifact-service blips must not fail the job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
id: ci-timings-artifact
with:
Expand Down
20 changes: 13 additions & 7 deletions .github/workflows/contributor-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ permissions:
jobs:
check-attribution:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
Expand All @@ -27,7 +28,9 @@ jobs:
exit 0
fi

# Check each email against AUTHOR_MAP in release.py
# An email is mapped if it has a file in contributors/emails/
# (one file per email — conflict-free) or an entry in the frozen
# legacy AUTHOR_MAP in scripts/release.py.
MISSING=""
while IFS= read -r email; do
# Skip teknium and bot emails
Expand All @@ -36,9 +39,12 @@ jobs:
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
continue # GitHub id+login noreply emails auto-resolve
fi

if [ -f "contributors/emails/${email}" ]; then
continue # mapped via the contributors directory
fi

if ! grep -qF "\"${email}\"" scripts/release.py 2>/dev/null; then
Expand All @@ -49,19 +55,19 @@ jobs:

if [ -n "$MISSING" ]; then
echo ""
echo "⚠️ New contributor email(s) not in AUTHOR_MAP:"
echo "⚠️ New contributor email(s) without a mapping:"
echo -e "$MISSING"
echo ""
echo "Please add mappings to scripts/release.py AUTHOR_MAP:"
echo "Add a mapping file (do NOT edit AUTHOR_MAP in release.py):"
echo -e "$MISSING" | while read -r line; do
email=$(echo "$line" | sed 's/^ *//' | cut -d' ' -f1)
[ -z "$email" ] && continue
echo " \"${email}\": \"<github-username>\","
echo " python3 scripts/add_contributor.py ${email} <github-username>"
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."
echo "✅ All contributor emails are mapped."
fi
18 changes: 12 additions & 6 deletions .github/workflows/deploy-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,15 @@ jobs:
# doesn't auto-deploy via the deploy-docs path.
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Trigger Vercel Deploy
run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"
run: curl -fsS --retry 3 --retry-delay 10 -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"

deploy-docs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
Expand All @@ -65,12 +67,14 @@ jobs:
python-version: '3.11'

- name: Install PyYAML for skill extraction
run: pip install pyyaml==6.0.2 httpx==0.28.1
uses: ./.github/actions/retry
with:
command: pip install pyyaml==6.0.2 httpx==0.28.1

- name: Prepare skills index (unified multi-source catalog)
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
run: |
Expand Down Expand Up @@ -150,8 +154,10 @@ jobs:
run: python3 website/scripts/generate-skill-docs.py

- name: Install dependencies
run: npm ci
working-directory: website
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: website

- name: Build Docusaurus
run: npm run build
Expand Down
35 changes: 22 additions & 13 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,13 @@ jobs:
run: uv python install 3.11

- name: Install Python dependencies (for docker tests)
run: |
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
uv sync --locked --python 3.11 --extra dev
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra dev

- name: Run docker integration tests
env:
Expand Down Expand Up @@ -188,15 +189,23 @@ jobs:
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
if [ "${{ github.event_name }}" = "release" ]; then
docker buildx imagetools create \
-t "${IMAGE_NAME}:${RELEASE_TAG}" \
"${args[@]}"
tags=(-t "${IMAGE_NAME}:${RELEASE_TAG}")
else
docker buildx imagetools create \
-t "${IMAGE_NAME}:main" \
-t "${IMAGE_NAME}:latest" \
"${args[@]}"
tags=(-t "${IMAGE_NAME}:main" -t "${IMAGE_NAME}:latest")
fi
# Retry: Docker Hub API + just-pushed digest eventual consistency
# can transiently fail the create; the operation is idempotent.
for i in 1 2 3; do
if docker buildx imagetools create "${tags[@]}" "${args[@]}"; then
break
fi
if [ "$i" = 3 ]; then
echo "::error::imagetools create failed after 3 attempts"
exit 1
fi
echo "::warning::imagetools create failed (attempt $i); retrying in 20s"
sleep 20
done

- name: Inspect image
env:
Expand Down
Loading
Loading