Skip to content
Open
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
16 changes: 7 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,16 @@ jobs:
steps:
- uses: actions/checkout@v4

# No `cache: pip` anywhere in this file. This fleet's runners keep
# `_work` on another drive behind a symlink, and setup-python's
# dependency-file glob does not see through it: it reports that no
# pyproject.toml exists and fails the job before anything is built,
# while all seven of them sit in `packages/`. A self-hosted runner also
# keeps `~/.cache/pip` between jobs on its own, so the action's cache
# was buying nothing here even when it worked.
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install the core dev extra
run: |
Expand Down Expand Up @@ -56,7 +62,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip

- name: Install torch CPU wheels
run: |
Expand Down Expand Up @@ -89,7 +94,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install torch CPU wheels
run: |
Expand Down Expand Up @@ -141,7 +145,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install torch CPU wheels
run: |
Expand Down Expand Up @@ -182,7 +185,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install torch CPU wheels
run: |
Expand Down Expand Up @@ -243,7 +245,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install torch CPU wheels
run: |
Expand Down Expand Up @@ -310,7 +311,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install docs dependencies
run: |
Expand All @@ -337,7 +337,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install the renderer PyPI itself uses
run: |
Expand Down Expand Up @@ -405,7 +404,6 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Build the core wheel with no extras
run: |
Expand Down
112 changes: 112 additions & 0 deletions .github/workflows/hub-verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: Hub artifact verification (sandbox)

# The community-upload verification sandbox (issue #48; the design of
# record is plans/hub_accounts_plan.md §7). Dispatched by
# spikeforge-hub-api once a committed upload enters its `verifying`
# state. That hub-api endpoint (spikeforge-hub-api#4) is not built as of
# this writing, so the `hub-artifact-verify` event type and the
# client_payload shape below are this repo's half of a contract the two
# repositories have to agree on -- see the pull request that introduced
# this file for the coordination note.
#
# SECURITY -- read before changing `runs-on` or the egress allowlist:
#
# This job runs `torch.load` on bytes a stranger uploaded. It MUST stay
# on a GitHub-hosted, ephemeral runner. It must NEVER run on the
# self-hosted `spikeforge-ci` runner that `deploy-hetzner.yml` uses --
# that runner holds the Hetzner deploy key, and running untrusted-content
# code on a persistent self-hosted runner is named in the plan (§7.2) as
# the single most predictable way this whole design could be
# compromised. This is deliberately the first workflow in this repository
# that does not use `spikeforge-ci`.
on:
repository_dispatch:
types: [hub-artifact-verify]

permissions:
contents: read

# One verification at a time per version; a second dispatch for the same
# version (a retry) should not race the first, but must not cancel it
# silently either -- a cancelled run still owes the hub API a report, or
# the version is stuck in `verifying` forever (the exact bug
# spikeforge-hub-api#4 exists to fix).
concurrency:
group: hub-verify-${{ github.event.client_payload.version_id }}
cancel-in-progress: false

env:
# CPU wheels keep this light; mirrors ci.yml's TORCH_INDEX_URL.
TORCH_INDEX_URL: https://download.pytorch.org/whl/cpu

jobs:
verify:
# GitHub-hosted only -- see the header comment. Do not add
# `spikeforge-ci` or any other self-hosted label to this job.
runs-on: ubuntu-latest
# A wall-clock ceiling independent of any one step's own timeout, per
# plans/hub_accounts_plan.md §7.2 ("a wall-clock timeout").
timeout-minutes: 20
steps:
# Restricts this job's DNS/network egress to exactly what it needs:
# GitHub's own checkout/runner endpoints, PyPI + the CPU wheel
# index for installing dependencies, and the two hub-api endpoints
# this job talks to (the artifact CDN and the verification
# callback) -- plans/hub_accounts_plan.md §7.2's "no network egress
# beyond the two endpoints this job actually needs", extended by
# the toolchain-setup endpoints every job on this runner requires
# regardless. If this allowlist ever needs to change, flip
# `egress-policy` to `audit` first, read the resulting job summary
# for what was actually contacted, then return it to `block`.
- name: Harden the runner's network egress
uses: step-security/harden-runner@v2
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
codeload.github.com:443
objects.githubusercontent.com:443
results-receiver.actions.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
download.pytorch.org:443
hub.spikeforge.net:443
cdn.spikeforge.net:443

- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
# No `cache: pip` here, deliberately: this job's whole point is
# to be a disposable sandbox for untrusted content, and a pip
# cache is one more thing that could be poisoned across runs.

- name: Install torch CPU wheels
run: |
python -m pip install --upgrade pip
pip install torch torchvision \
--index-url ${{ env.TORCH_INDEX_URL }}

- name: Install core + targets + hub editable
run: |
pip install -e "./packages/spikeforge[dev,nir]" \
-e ./packages/spikeforge-targets \
-e ./packages/spikeforge-hub

- name: Run the verification pipeline and post the signed report
env:
PYTHONPATH: ${{ github.workspace }}/scripts
SPIKEFORGE_HUB_VERIFY_VERSION_ID: >-
${{ github.event.client_payload.version_id }}
SPIKEFORGE_HUB_VERIFY_ARTIFACT_URL: >-
${{ github.event.client_payload.artifact_url }}
# Fixed to this repository's own configuration -- never read
# from the dispatch payload. The callback destination must not
# be data a compromised or malformed dispatch could redirect;
# see hub_verify/cli.py's module docstring.
SPIKEFORGE_HUB_API_BASE_URL: ${{ vars.SPIKEFORGE_HUB_API_BASE_URL }}
SPIKEFORGE_HUB_VERIFICATION_SECRET: >-
${{ secrets.SPIKEFORGE_HUB_VERIFICATION_SECRET }}
run: python -m hub_verify.cli
2 changes: 1 addition & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[pytest]
testpaths = tests
pythonpath = .
pythonpath = . scripts
addopts = --cov=spikeforge --cov-report=term-missing
markers =
network: fetches a dataset, so it reaches the network on a cold
Expand Down
27 changes: 27 additions & 0 deletions scripts/hub_verify/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""The community-upload verification sandbox runner (issue #48, P4).

Invoked by ``.github/workflows/hub-verify.yml`` on a ``repository_dispatch``
from ``spikeforge-hub-api``, on a GitHub-hosted (never self-hosted) runner:
the self-hosted ``spikeforge-ci`` runner holds the Hetzner deploy key, and
running ``torch.load`` on a stranger's bytes there is the single most
predictable way this design could be compromised
(``plans/hub_accounts_plan.md`` §7.2).

This package is CI scaffolding, not a distributed package: nothing under
``spikeforge*`` imports it, and it ships in no wheel. It reuses the existing,
already-shipped pipeline rather than reimplementing any of it:

* :mod:`spikeforge.serving.bundle` -- checksum/manifest verification and the
``weights_only=True`` load already used for every bundle.
* :mod:`spikeforge_hub.inspect` / :mod:`spikeforge_hub.compat` -- the same
structural inspection and preset classification the curated catalog uses.
* :mod:`spikeforge.nir_bridge` -- the same NIR export and independent
reference-interpreter drift check invariant 4 of ``rules.md`` requires.
* :mod:`spikeforge_targets.energy` -- the same SOP/MAC/AC accounting used
elsewhere in the project.

See :mod:`hub_verify.cli` for the orchestration entry point and
:mod:`hub_verify.report` for the report/callback contract this runner
implements against ``spikeforge-hub-api``'s (not yet built, as of this
writing) ``POST /internal/v1/verifications/{version_id}``.
"""
56 changes: 56 additions & 0 deletions scripts/hub_verify/callback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Post the signed verification report back to the hub API.

Follows the same ``urllib``-only request pattern already used in this
monorepo (:class:`spikeforge_clients.transport.HttpTransport`) rather than
adding a new HTTP dependency: this script installs into a throwaway CI
job, but the project's stated convention is stdlib-only HTTP regardless.
"""

import json
import urllib.error
import urllib.request
from typing import Final

from hub_verify.errors import CallbackError
from hub_verify.signing import SIGNATURE_HEADER, sign_body

_TIMEOUT_SECONDS: Final[int] = 30


def _url(base_url: str, version_id: str) -> str:
"""Return the internal verification-callback URL for ``version_id``."""
return f"{base_url.rstrip('/')}/internal/v1/verifications/{version_id}"


def post_report(
base_url: str, version_id: str, report: dict, secret: str
) -> None:
"""POST ``report`` for ``version_id``, signed with ``secret``.

Raises :class:`CallbackError` on any failure to deliver it -- unlike an
artifact rejection, a delivery failure is not something this job can
itself report to the hub, so it must fail the job loudly instead of
silently leaving the version stuck in ``verifying``.
"""
body = json.dumps(report, sort_keys=True).encode("utf-8")
request = urllib.request.Request(
_url(base_url, version_id),
data=body,
method="POST",
headers={
"Content-Type": "application/json",
SIGNATURE_HEADER: sign_body(body, secret),
},
)
try:
with urllib.request.urlopen(
request, timeout=_TIMEOUT_SECONDS
) as reply:
if reply.status >= 400:
raise CallbackError(f"hub API replied {reply.status}")
except urllib.error.HTTPError as error:
raise CallbackError(
f"hub API replied {error.code}: {error.read()!r}"
) from error
except (urllib.error.URLError, OSError) as error:
raise CallbackError(f"callback delivery failed: {error}") from error
Loading
Loading