Skip to content
Closed
90 changes: 90 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e '.[dev]'
- run: ruff check --output-format=github .

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# The floor is `requires-python`; the ceiling is whatever is current.
# Hermes decides which of these a real install runs on, so the plugin
# should not be the thing that narrows it.
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e '.[dev]'
- run: python -m pytest -q

package:
# A plugin that installs without its manifest, dashboard bundle or skill
# registers nothing, and the failure is silent — the platform simply never
# appears. Building the artifact and looking inside it is the only way that
# gets caught before a release.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install build twine
- run: python -m build
- run: twine check dist/*
- name: The wheel must carry the non-Python half of the plugin
run: |
set -euo pipefail
wheel=$(ls dist/*.whl)
echo "inspecting $wheel"
contents=$(python -m zipfile --list "$wheel")
for required in \
hookdeck/plugin.yaml \
hookdeck/dashboard/manifest.json \
hookdeck/dashboard/dist/index.js \
hookdeck/skills/triage-webhook-failures/SKILL.md
do
if ! grep -qF "$required" <<<"$contents"; then
echo "::error::$required is missing from the wheel"
exit 1
fi
echo " ✓ $required"
done
- name: The entry point Hermes discovers the plugin by must be declared
run: |
set -euo pipefail
pip install dist/*.whl
python - <<'PY'
from importlib.metadata import entry_points
found = entry_points(group="hermes_agent.plugins")
names = {e.name: e.value for e in found}
assert names.get("hookdeck") == "hookdeck", names
print("✓ hermes_agent.plugins entry point:", names)
PY
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
90 changes: 90 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Tag-driven release to PyPI.
#
# 1. bump `__version__` in hookdeck/__init__.py (pyproject reads it from there)
# 2. update CHANGELOG.md
# 3. git tag v0.2.0 && git push --tags
#
# Publishing uses PyPI Trusted Publishing (OIDC), so there is no API token in
# the repository to leak or rotate. It needs a one-time setup on PyPI:
# Project → Publishing → add a GitHub publisher for hookdeck/hermes-hookdeck,
# workflow `release.yml`, environment `pypi`.
name: Release

on:
push:
tags: ["v*"]
workflow_dispatch:

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e '.[dev]' build twine

- name: The tag and the package version must agree
if: startsWith(github.ref, 'refs/tags/v')
run: |
set -euo pipefail
tagged="${GITHUB_REF_NAME#v}"
declared=$(python -c 'import hookdeck; print(hookdeck.__version__)')
if [ "$tagged" != "$declared" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match hookdeck.__version__ ($declared)"
exit 1
fi
echo "✓ releasing $declared"

# A release that cannot pass its own test suite is not a release.
- run: ruff check .
- run: python -m pytest -q

- run: python -m build
- run: twine check dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/hermes-hookdeck
permissions:
# The OIDC token pypa/gh-action-pypi-publish exchanges for an upload
# token. Nothing else in this workflow needs it, which is why it is
# scoped to this job rather than the file.
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1

github-release:
needs: publish
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish the GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" dist/* \
--title "$GITHUB_REF_NAME" \
--notes "See [CHANGELOG.md](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/CHANGELOG.md)."
37 changes: 37 additions & 0 deletions .github/workflows/upstream-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# The test suite runs against tests/hermes_stub.py, because the plugin lives
# outside the Hermes tree and there is no other way to exercise the ingest path
# without a Hermes checkout. The blind spot that buys is real: the stub cannot
# notice when the thing it stands in for changes.
#
# So this asks upstream directly, on a schedule rather than on every PR — the
# answer changes when Hermes changes, not when this repo does. A failure here
# is a heads-up, not a broken build.
name: Upstream contract

on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
push:
paths:
- "scripts/check_upstream_contract.py"
- "tests/hermes_stub.py"
- ".github/workflows/upstream-contract.yml"

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Fetch the parts of Hermes this plugin borrows from
run: |
git clone --depth 1 --filter=blob:none --sparse \
https://github.com/NousResearch/hermes-agent.git upstream
git -C upstream sparse-checkout set gateway agent
- run: python scripts/check_upstream_contract.py upstream
65 changes: 65 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Changelog

Notable changes per release. Versions follow [semantic versioning](https://semver.org),
with the caveat that until 1.0 the config surface — `platforms.hookdeck.extra`
— may change in a minor release, and any such change is listed here.

## Unreleased

### Fixed

- A signature header containing a non-ASCII byte answered 500 instead of 401.
The 500 skipped the `EMITTED_STATUS_RETRYABLE` guard and fell inside the
provisioned retry rule's `500-599` range, so Hookdeck retried a forged
request rather than dropping it.
- A delivery's event id no longer falls back to a bare `X-Request-ID` header.
That header is not a Hookdeck identifier, is not subject to `header_prefix`,
and is not unique per delivery — one request fans out to one event per
matching connection. Deliveries genuinely missing `x-hookdeck-eventid` are
processed with the existing warning that dedup and retry are unavailable.
- The marker tracking a `sync` run that outlasted its timeout is now cleared on
every terminal path, not only on success. It previously survived an exhausted
retry budget or an abandoned hand-back, and a later run of the same event id
would be treated as having been acked early when it had not.

### Added

- Continuous integration: lint, a test matrix across Python 3.10–3.13, and a
packaging job that asserts the built wheel still carries `plugin.yaml`, the
dashboard bundle and the bundled skill — without which the plugin installs
but registers nothing.
- A weekly check that the Hermes internals this plugin subclasses and calls
still exist upstream, since the test suite otherwise runs entirely against
`tests/hermes_stub.py` and cannot notice the real thing moving.
- Ruff, with `BLE001` selected so each deliberate blind `except` carries its
reason at the point it is written.

### Changed

- The package version is read from `hookdeck.__version__` rather than declared
a second time in `pyproject.toml`.

### Documentation

- The README opens by saying what Hermes Agent and Hookdeck Event Gateway are,
rather than assuming both.
- It also says *which* Hookdeck. This is the Event Gateway — inbound events
arriving at your agent — and not Outpost, which is the other direction. And
"platform" in `kind: platform` is Hermes' word for a source of inbound work,
not a reference to the Hookdeck platform.
- A new architecture section with two diagrams: topology, and a sequence diagram
for the ack-then-hand-back contract that the reliability rests on. The three
different things called "CLI" are separated there.
- A new section listing Hookdeck capabilities the plugin does not currently use
— the Publish API, bulk operation plans and cancellation, request replay,
ignored-event retry, issue triggers, transformations, the wider metrics — so
the edge of `hookdeck/api.py` is not mistaken for the edge of the product.
- The "no pull API" limitation now explains what the durability claim actually
rests on: an event is recoverable because a delivered-but-failed event stays
retryable, not because anything holds a lease on it.

## 0.1.0

First release. Hookdeck platform adapter, `hermes hookdeck` operator commands,
the `hookdeck` agent toolset, the `triage-webhook-failures` skill and the
dashboard tab.
Loading