Skip to content

Commit 6555297

Browse files
qazbnm456claude
andcommitted
ci: harden CI/release workflows (least-privilege token, SHA-pin third-party actions)
Public repo about to publish to PyPI, so the supply-chain threat model applies most here. Borrowed from the same hardening applied to a downstream consumer (itself borrowed from a public awesome-list repo's CI posture): - ci.yml: add `permissions: contents: read` (it only checks out + tests/lints; specifying permissions also drops every unlisted scope to `none`, so a compromised action on an untrusted fork PR can't push/tag/open issues) and `concurrency` cancel-in-progress. - ci.yml + release.yml: SHA-pin the third-party actions — `astral-sh/setup-uv` (v5.4.2) and, highest blast radius since it uploads to PyPI, `pypa/gh-action-pypi-publish` (v1.14.0). GitHub-owned checkout/*-artifact stay on major tags. release.yml's OIDC Trusted Publishing is untouched. Also correct CLAUDE.md: both consumers now pin the kit as a git dep (not an editable path dep). Test/lint commands are unchanged — pure config hardening. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7f5407f commit 6555297

4 files changed

Lines changed: 29 additions & 10 deletions

File tree

.github/workflows/ci.yml

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@ on:
55
branches: [main]
66
pull_request:
77

8-
# Action versions (checkout@v4, setup-uv@v5) are current majors; bump if a newer
9-
# major ships. CI needs no live model, no vLLM, no network, no Deno: the
10-
# dspy-bearing tests use a DummyLM or skip when dspy is absent.
8+
# Least privilege: this workflow only checks out code and runs tests/lint — it never writes. Specifying
9+
# `permissions:` also drops every unlisted scope to `none`, so a compromised dep/action on an untrusted
10+
# fork PR can't use the token to push, tag, or open issues. The third-party setup-uv action is SHA-pinned
11+
# (a repointed tag could inject code that runs with the token); GitHub-owned checkout stays on a major tag.
12+
# CI needs no live model, no vLLM, no network, no Deno: the dspy-bearing tests use a DummyLM or skip when
13+
# dspy is absent.
14+
permissions:
15+
contents: read
16+
17+
concurrency:
18+
group: ci-${{ github.ref }}
19+
cancel-in-progress: true
1120

1221
jobs:
1322
test:
@@ -20,7 +29,7 @@ jobs:
2029
steps:
2130
- uses: actions/checkout@v4
2231
- name: Install uv
23-
uses: astral-sh/setup-uv@v5
32+
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
2433
with:
2534
python-version: ${{ matrix.python-version }}
2635
enable-cache: true
@@ -34,7 +43,7 @@ jobs:
3443
steps:
3544
- uses: actions/checkout@v4
3645
- name: Install uv
37-
uses: astral-sh/setup-uv@v5
46+
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
3847
with:
3948
enable-cache: true
4049
- name: Lint

.github/workflows/release.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ name: Release
66
# owner = qazbnm456, repo = rlm-kit, workflow = release.yml, environment = pypi
77
# 2. In this repo's settings, create an environment named `pypi`.
88
# Then publishing a GitHub Release (from a `vX.Y.Z` tag) builds + uploads automatically.
9-
# Action majors (checkout@v4, setup-uv@v5, *-artifact@v4, pypi-publish@release/v1) are
10-
# current; bump if a newer major ships.
9+
# Supply-chain: the two third-party actions are SHA-pinned — setup-uv, and (highest blast radius, it
10+
# uploads to PyPI) pypi-publish. Bump a SHA and its trailing version comment together. GitHub-owned
11+
# checkout/*-artifact stay on major tags.
1112

1213
on:
1314
release:
@@ -22,7 +23,7 @@ jobs:
2223
steps:
2324
- uses: actions/checkout@v4
2425
- name: Install uv
25-
uses: astral-sh/setup-uv@v5
26+
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
2627
- name: Build sdist + wheel
2728
run: uv build
2829
- uses: actions/upload-artifact@v4
@@ -42,4 +43,4 @@ jobs:
4243
name: dist
4344
path: dist/
4445
- name: Publish to PyPI
45-
uses: pypa/gh-action-pypi-publish@release/v1
46+
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,15 @@ surfaced by dogfooding a real downstream consumer.
284284
(or `RLMConfig(max_tokens=None)`) to defer to the server. *(diagnosed by capturing per-call
285285
`finish_reason`/`reasoning_len`/`completion_tokens` on a telnet run; verified: 16 calls, 0 empty
286286
/ 0 length-truncations at 16384 vs an empty at the 1000 cap.)*
287+
- **CI/release workflows hardened for the public-repo + PyPI-publish threat model** (`.github/workflows/`).
288+
`ci.yml` now runs least-privilege (`permissions: contents: read` — it only checks out and tests/lints;
289+
specifying `permissions:` drops every unlisted scope to `none`, so a compromised action on an untrusted
290+
fork PR can't push, tag, or open issues) with `concurrency` cancel-in-progress. Both workflows now
291+
SHA-pin their third-party actions — `astral-sh/setup-uv`, and (highest blast radius, it uploads to PyPI)
292+
`pypa/gh-action-pypi-publish` — so a repointed tag can't inject code that runs with the token; GitHub-owned
293+
`checkout`/`*-artifact` stay on major tags. `release.yml`'s OIDC Trusted Publishing (no API token,
294+
`id-token: write` scoped to just the publish job) is untouched. *(Ported from the same hardening applied to
295+
a downstream consumer, itself borrowed from a public awesome-list repo's CI posture.)*
287296
- **New `RLMConfig.adapter` (`RLM_ADAPTER`) selects the structured-output adapter; default
288297
`"json"` (schema-guided)** (`config.py`, `runtime.py`). Modes: `"json"` (default), `"chat"`,
289298
`"default"`.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ One companion rule ships under `.claude/rules/`:
125125
## Consumer-driven hardening
126126

127127
- This kit is driven by a real downstream consumer (a task that builds on the
128-
scaffold via an editable path dep). That dogfooding is the design loop: when the consumer
128+
scaffold, pinning the kit as a git dep — overlaid editable for local co-dev). That dogfooding is the design loop: when the consumer
129129
forces a workaround, log the **reusable** gap and fix it in the kit — do not special-case
130130
the consumer. Generic mechanics get promoted here via the base/wrap split (a generic base +
131131
syntactic guard + factory in the kit; the provider + tracing in the consumer); consumer-specific

0 commit comments

Comments
 (0)