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
17 changes: 13 additions & 4 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
# grouped pull request a month, because ten one-line pull requests in a morning is noise that
# gets merged unread.
#
# No pip entry on purpose. The version floors in pyproject.toml are deliberate minimums, each
# with a reasoned comment, and CI installs the latest release of every extra anyway, so a
# floor bump would exclude working installations for no gain. Security updates are a
# repository setting (Dependabot alerts and security updates), not this file, and stay on.
# The pip entry covers `requirements/` and nothing else. Those are the hash-pinned files CI
# installs from (`scripts/lock.sh` writes them), and a pin nobody moves is a pin to last
# year's bugs, so they move weekly, grouped. The version floors in pyproject.toml are not in
# scope: they are deliberate minimums, each with a reasoned comment, and a floor bump would
# exclude working installations for no gain. Security updates are a repository setting
# (Dependabot alerts and security updates), not this file, and stay on.
version: 2
updates:
- package-ecosystem: github-actions
Expand All @@ -16,3 +18,10 @@ updates:
groups:
actions:
patterns: ["*"]
- package-ecosystem: pip
directory: /requirements
schedule:
interval: weekly
groups:
locks:
patterns: ["*"]
31 changes: 17 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,15 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Install
# Every dependency by hash from `requirements/ci.txt`, then the checkout itself with
# `--no-deps` and against the backend that lock carries (`requirements/in/backend.in`):
# what this job resolves is what `scripts/lock.sh` wrote down and somebody reviewed, not
# what PyPI happened to serve this morning. The gateway's tests need its
# extra; T30 still proves `import ctrlrun` does not touch it, in a subprocess, whether
# or not it happens to be installed here.
run: |
python -m pip install --upgrade pip
# The gateway's tests need its extra; T30 still proves `import ctrlrun` does not
# touch it, in a subprocess, whether or not it happens to be installed here.
pip install -e ".[dev,gateway,otel,identity,postgres]"
pip install --require-hashes -r requirements/ci.txt
pip install --no-deps --no-build-isolation -e .

# A claim about the environment is worth nothing until something checks it: if the
# service failed to start, the Postgres tests would silently skip and the backend
Expand Down Expand Up @@ -238,10 +242,9 @@ jobs:
# test must be this checkout, so its dependencies are installed on the line above and
# the adapters are installed against it.
run: |
python -m pip install --upgrade pip
pip install -e ".[dev,gateway,otel,identity]"
pip install langgraph langchain langchain-openai openai-agents
pip install --no-deps -e adapters/langgraph -e adapters/openai-agents
pip install --require-hashes -r requirements/adapters.txt
pip install --no-deps --no-build-isolation -e .
pip install --no-deps --no-build-isolation -e adapters/langgraph -e adapters/openai-agents

# The frameworks are installed, so a skip here is a failure and not a configuration.
# `-p no:randomly` is not used and no marker is filtered: this is the whole of both files.
Expand Down Expand Up @@ -305,9 +308,8 @@ jobs:
# gateway's client needs the extra installed, and a block that skipped would be a
# sample nobody ran.
run: |
python -m pip install --upgrade pip
pip install -e "./ctrlrun[dev,gateway,otel,identity]"
pip install griffe pyyaml
pip install --require-hashes -r ctrlrun/requirements/docs.txt
pip install --no-deps --no-build-isolation -e ./ctrlrun

# `_core.py` raises when it cannot find the library, so this prints a path or the job
# stops here. A documentation check that ran against no source would be green and
Expand Down Expand Up @@ -395,8 +397,8 @@ jobs:

- name: Build
run: |
python -m pip install --upgrade pip build twine
python -m build
pip install --require-hashes -r requirements/build.txt
python -m build --no-isolation

- name: Metadata renders on PyPI
run: twine check --strict dist/*
Expand All @@ -406,8 +408,9 @@ jobs:
# packaging it downstream. This is the guard; MANIFEST.in is the fix.
run: |
mkdir -p /tmp/sdist && tar xzf dist/*.tar.gz -C /tmp/sdist --strip-components=1
pip install --require-hashes -r requirements/ci.txt
cd /tmp/sdist
pip install ".[dev,gateway,otel,identity]"
pip install --no-deps --no-build-isolation -e .
pytest -q

- name: The quick start works from the wheel
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ jobs:

- name: Install
run: |
python -m pip install --upgrade pip
pip install -e .
pip install --require-hashes -r requirements/fuzz.txt
pip install --no-deps --no-build-isolation -e .

# The gate. No Atheris, so it cannot be skipped by a toolchain problem, and it fails on
# a seed that regressed rather than on a campaign that happened to find one.
Expand All @@ -41,7 +41,7 @@ jobs:
# a fuzzing job that quietly stops fuzzing is the false green this repository keeps
# finding in other costumes.
- name: Install Atheris
run: pip install atheris
run: pip install --require-hashes -r requirements/atheris.txt

# Bounded, or an unbounded `atheris.Fuzz()` hangs until the job timeout, which reads as a
# broken build rather than as a finding. `-runs` is not used: wall-clock is what makes a
Expand Down
12 changes: 7 additions & 5 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ jobs:

- name: Build
run: |
python -m pip install --upgrade pip build twine packaging
python -m build --outdir dist "${{ steps.target.outputs.path }}"
pip install --require-hashes -r requirements/build.txt
Comment thread
coderabbitai[bot] marked this conversation as resolved.
python -m build --no-isolation --outdir dist "${{ steps.target.outputs.path }}"

- name: Metadata renders on PyPI
run: twine check --strict dist/*
Expand Down Expand Up @@ -101,11 +101,13 @@ jobs:
if: steps.target.outputs.kernel == 'true'
run: |
mkdir -p /tmp/sdist && tar xzf dist/*.tar.gz -C /tmp/sdist --strip-components=1
cd /tmp/sdist
# With the extras: without them the gateway, ACS, elicitation and OTel modules
# skip, and this gate guards an upload to a version number that can never be
# reused. It should be no weaker than the one on main.
pip install ".[dev,gateway,otel,identity]"
# reused. It should be no weaker than the one on main, so it installs from the
# same hashed lock.
pip install --require-hashes -r requirements/ci.txt
cd /tmp/sdist
pip install --no-deps --no-build-isolation -e .
pytest -q

- name: The quick start works from the wheel
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,13 @@ jobs:
with:
python-version: "3.11"

# `requirements/build.txt` is read from the tag's own tree, which every tag from 0.10.0
# on carries. The tags before it all have a Release already, so they never reach here.
- name: Build the distributions at the tag
if: steps.existing.outputs.exists == 'false'
run: |
python -m pip install --upgrade pip build
python -m build
pip install --require-hashes -r requirements/build.txt
python -m build --no-isolation

# Signed provenance for the artifacts this release is about to carry. The action signs
# against the workflow's OIDC identity, so what it attests is *this* workflow, at *this*
Expand Down
68 changes: 68 additions & 0 deletions .github/workflows/scorecard-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Scorecard gate

# OpenSSF Scorecard's file-based checks, run against the pull request's tree and failed if any
# would come back lower than `main` publishes today. `scorecard.yml` is the third party's
# reading of `main` after a merge; this is the same reading before it, so a change that unpins
# an install, widens a token or drops the security policy is red here rather than a lower badge
# on Monday. Only the checks a pull request's own files decide are gated: the ones that need
# the GitHub API (branch protection, code review, maintenance) are read from `main` by
# `scorecard.yml` and cannot be read from a pull request.
on:
pull_request:

permissions:
contents: read

jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# On a `pull_request` event the action analyses the checkout (`--local .`) and publishes
# nothing; the published result stays `scorecard.yml`'s.
- uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
with:
results_file: results.json
results_format: json
publish_results: false

- name: Nothing this repository controls may score below what main publishes
run: |
python3 - <<'PY'
import json, sys
checks = {c["name"]: c for c in json.load(open("results.json"))["checks"]}
# What `main` scores today on the checks a pull request's tree decides. A floor moves
# up when a check improves and never down: a lower number here is a regression.
floors = {
"Binary-Artifacts": 10,
"Dangerous-Workflow": 10,
"Dependency-Update-Tool": 10,
"Fuzzing": 10,
"License": 9, # local mode cannot confirm the OSI listing; main reads 10
"Packaging": 10,
"Pinned-Dependencies": 10,
"SAST": 10,
"Security-Policy": 10,
"Token-Permissions": 10,
"Vulnerabilities": 10,
}
failed = []
for name, floor in floors.items():
check = checks.get(name)
if check is None:
failed.append(f"{name}: not reported")
continue
print(f"{check['score']:>3} {name}: {check['reason']}")
for detail in check.get("details") or []:
if detail.startswith("Warn"):
print(f" {detail}")
if check["score"] < floor:
failed.append(f"{name}: {check['score']} < {floor}")
if failed:
print("::error::" + "; ".join(failed))
sys.exit(1)
print("no check below its floor")
PY
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ any change to one appears here.

## [Unreleased]

### Changed

- **CI installs from hashed locks.** Every `pip install` in a workflow now reads a
`requirements/*.txt` that `scripts/lock.sh` writes with `uv pip compile --universal
--generate-hashes`, under `--require-hashes`, and then installs the checkout itself with
`--no-deps --no-build-isolation` against the setuptools the same lock carries; `python -m build`
runs with `--no-isolation` for the same reason. What a job resolves, the build backend included,
is what somebody generated and reviewed, not what PyPI served that morning. The version floors
in `pyproject.toml` are unchanged: they are what a user may install against, and the locks are
what CI does. Dependabot moves the locks weekly, grouped.
- **A Scorecard gate on every pull request.** `scorecard-gate.yml` runs OpenSSF Scorecard's
file-based checks against the pull request's tree and fails it if any would come back below
what `main` publishes, so an unpinned install, a widened token or a vulnerable pin is red
before the merge rather than a lower badge after it. `tests/test_repository_signals.py`
asserts the same rule for `pip install`, so the suite catches it first.

## [0.9.0] - Envelope

*Undated until the tag.*
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ that and asserts nothing skipped.
Use the project's own interpreter for every check. A bare `python` from elsewhere gives two
spurious failures, one of which is a false green.

CI does not install the extras the way the block above does. It installs from
`requirements/*.txt`, hash-pinned locks that `scripts/lock.sh` writes with `uv pip compile`,
and then the checkout with `--no-deps`; the floors in `pyproject.toml` are unchanged by that.
When a dependency or an extra changes, run `scripts/lock.sh` and commit what it rewrote, or CI
installs the old resolution against the new declaration.

## Specification first

Every version is a specification before it is code: `docs/SPEC-v0.1.md` through
Expand Down
Loading