feat: support Runtime Manager default profile assets #10
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 :main or release-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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| 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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 | |
| 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 | |
| # --------------------------------------------------------------------- | |
| # Run the docker-integration test suite against the freshly-built | |
| # image already loaded into the local daemon (`:test`). These tests | |
| # are excluded from the sharded `tests.yml :: test` matrix on purpose | |
| # (see `_SKIP_PARTS` in scripts/run_tests_parallel.py) because each | |
| # shard would otherwise reach the session-scoped ``built_image`` | |
| # fixture in ``tests/docker/conftest.py`` and start a 3-7min | |
| # ``docker build`` under a 180s pytest-timeout cap — guaranteed to | |
| # die in fixture setup. | |
| # | |
| # Piggybacking here avoids a second image build: the smoke test | |
| # already proved the image loads + runs, so the daemon has it under | |
| # `${IMAGE_NAME}:test` and we just point ``HERMES_TEST_IMAGE`` at | |
| # that. The fixture's ``HERMES_TEST_IMAGE`` branch (see | |
| # tests/docker/conftest.py:62-63) short-circuits the rebuild. | |
| # | |
| # Why this job and not a standalone one: the image is 5GB+; passing | |
| # it between jobs via ``docker save``/``upload-artifact`` is slower | |
| # than the build itself. Reusing the existing daemon state is the | |
| # cheapest path to coverage on every PR that touches docker code. | |
| # --------------------------------------------------------------------- | |
| - name: Install uv (for docker tests) | |
| uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 | |
| - name: Set up Python 3.11 (for docker tests) | |
| run: uv python install 3.11 | |
| - name: Install Python dependencies (for docker tests) | |
| run: | | |
| uv venv .venv --python 3.11 | |
| source .venv/bin/activate | |
| # ``dev`` extra pulls in pytest, pytest-asyncio, pytest-timeout — | |
| # 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 pip install -e ".[dev]" | |
| - name: Run docker integration tests | |
| env: | |
| # Skip rebuild; use the image already loaded by the build step. | |
| HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test | |
| # Match the policy in tests.yml :: test job — no accidental | |
| # real-API calls from inside the harness. | |
| OPENROUTER_API_KEY: "" | |
| OPENAI_API_KEY: "" | |
| NOUS_API_KEY: "" | |
| run: | | |
| source .venv/bin/activate | |
| python -m pytest tests/docker/ -v --tb=short | |
| - name: Log in to Docker Hub | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' | |
| uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | |
| 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 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' | |
| uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 | |
| 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| 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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 | |
| 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | |
| 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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 | |
| 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 :main; on | |
| # releases it produces :<release_tag_name>. | |
| # | |
| # 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. | |
| # --------------------------------------------------------------------------- | |
| 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: | |
| 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | |
| with: | |
| 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 | |
| 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 | |
| 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" | |
| else | |
| echo "Existing :latest is newer than this release (likely a backport) — leaving it alone." | |
| echo "push_latest=false" >> "$GITHUB_OUTPUT" | |
| 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}" |