-
Notifications
You must be signed in to change notification settings - Fork 0
226 lines (215 loc) · 11.7 KB
/
Copy pathrelease-python.yml
File metadata and controls
226 lines (215 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# Publishing `in-lockstep`, the one Python distribution this repository builds.
#
# It used to build two. `in-lockstep-exec` was the deterministic-executor runtime a compiled
# pipeline invoked by name, and it was deleted along with the last thing that invoked it — see
# ADR 0001. Its 0.1.0 stays on PyPI, yanked; its image and its `exec-v0.1.0` tag went on
# 2026-09-02. Nothing here republishes any of it.
#
# **No API token exists anywhere in this.** Publication is by Trusted Publishing: PyPI verifies a
# short-lived OIDC credential minted by Actions for this repository, this workflow and this
# environment. There is no secret to leak, rotate, or scope wrongly — which is the same argument
# the rest of the framework makes about agents never holding credentials.
#
# The environment is still named after the project rather than left at a default. PyPI identifies a
# publisher by owner, repository, workflow and environment together, so the name is half of what
# scopes this credential to the one project it may upload — and it is what let a second
# distribution exist here without one credential speaking for both.
name: release-python
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
publish:
description: Upload to PyPI (otherwise build and prove only)
type: boolean
default: false
permissions:
contents: read
jobs:
build:
name: Build and prove
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
python-version: '3.11'
- name: The tag names the version being published
# A tag and a version that disagree publish something nobody asked for, under a name nobody
# can withdraw: PyPI never lets a version be reused, even after a delete.
if: startsWith(github.ref, 'refs/tags/v')
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME#v}"
version=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
if [ "$version" != "$tag" ]; then
echo "pyproject.toml is $version, but the tag says $tag"; exit 1
fi
# And what `--version` will print, which is a second declaration in a second file. The
# suite keeps the two in step; this checks the one that ends up on PyPI under the tag.
runtime=$(grep -m1 '^__version__ = ' src/in_lockstep/__init__.py | cut -d'"' -f2)
if [ "$runtime" != "$tag" ]; then
echo "in_lockstep.__version__ is $runtime, but the tag says $tag"; exit 1
fi
echo "publishing $tag"
- run: uv sync
- name: The suite passes
run: uv run pytest -q
- name: Build the distribution
# Into a named subdirectory rather than a bare `dist/`, because the publish step below
# points at `dist/in-lockstep` and a Trusted Publishing exchange uploads whatever it finds
# in the directory it is given.
run: |
set -euo pipefail
uv build --package in-lockstep --out-dir dist/in-lockstep
uvx twine check dist/*/*
- name: A clean install carries what is not Python
# The failure this exists for: the framework ships twenty-four prompts as markdown and
# twenty-seven eval cases as JSON. Neither is a `.py` file, so a packaging change can drop
# them while every test in this repository still passes — they are read from the source
# tree here and from site-packages everywhere else.
#
# `show-prompt` reads a prompt body and `eval report` reads the corpus, so both fail if
# the data did not travel. The compiler this check used to exercise was removed in phase 7;
# the failure mode it was written for did not go with it.
run: |
set -euo pipefail
python3 -m venv /tmp/proof
/tmp/proof/bin/pip install -q dist/in-lockstep/*.whl
/tmp/proof/bin/in-lockstep --version
# A prompt body, read from site-packages rather than from a checkout. Captured first,
# then searched: `grep -q` closes the pipe on its first match, the CLI then takes EPIPE
# on a later write and exits 1, and `pipefail` fails the step on a prompt that was
# correct. That race never fires on a 64KB macOS pipe and fired on the first tag here.
prompt=$(/tmp/proof/bin/in-lockstep show-prompt security)
grep -q "guardrail:baseline" <<<"$prompt"
# And the eval corpus, the same way.
corpus=$(/tmp/proof/bin/in-lockstep eval report)
grep -q "27" <<<"$corpus"
# The site's first promise, kept from site-packages alone: the shipped replay runs in
# an empty directory with no module, no key and no budget. Five clean installs of 0.2.0
# were refused here (#174); this step would have caught that on the first tag.
mkdir -p /tmp/proof-empty && cd /tmp/proof-empty
replay=$(/tmp/proof/bin/in-lockstep review --offline)
test "$(grep -c 'review.security:' <<<"$replay")" = 2
grep -q '\$0.0000' <<<"$replay"
mkdir -p /tmp/proof-repo && cd /tmp/proof-repo
/tmp/proof/bin/in-lockstep init
test -f .lockstep/lockstep.py
test -f .github/workflows/lockstep.yml
# The two first-time users' repositories (#167): pytest and ruff in the repository's
# own .venv and neither in the tool's. The installed tool ran its own interpreter and
# reported "ruff is not installed" and a red suite. selfcheck has to run theirs.
mkdir -p /tmp/proof-theirs/tests && cd /tmp/proof-theirs
python3 -m venv .venv && .venv/bin/pip install -q pytest ruff
printf '[tool.pytest.ini_options]\ntestpaths = ["tests"]\n[tool.ruff]\n' > pyproject.toml
printf 'def test_ok():\n assert True\n' > tests/test_ok.py
# `env -u GITHUB_WORKSPACE`: detect() prefers that variable to the working directory,
# and on a runner it names this checkout, not the fixture.
git init -q && env -u GITHUB_WORKSPACE /tmp/proof/bin/in-lockstep init >/dev/null
listing=$(env -u GITHUB_WORKSPACE /tmp/proof/bin/in-lockstep ls)
grep -q '/tmp/proof-theirs/.venv/bin/python' <<<"$listing"
grep -q '/tmp/proof-theirs/.venv/bin/ruff' <<<"$listing"
env -u GITHUB_WORKSPACE /tmp/proof/bin/in-lockstep run selfcheck --paths . > selfcheck.txt
grep -q 'validate succeeded' selfcheck.txt
grep -q 'test succeeded' selfcheck.txt
# The CI form of the same bug (#185): a scaffolded work job starts with no environment
# at all, and `provision` is the step that builds one. A uv fixture with nothing
# installed: the lockfile's install makes .venv, the same `ls` that showed PATH now
# shows the fixture's own interpreter, and selfcheck runs there.
mkdir -p /tmp/proof-provision/tests && cd /tmp/proof-provision
printf '[project]\nname = "demo"\nversion = "0"\nrequires-python = ">=3.11"\ndependencies = []\n\n[dependency-groups]\ndev = ["pytest", "ruff"]\n\n[tool.pytest.ini_options]\ntestpaths = ["tests"]\n\n[tool.ruff]\n' > pyproject.toml
printf 'def test_ok():\n assert True\n' > tests/test_ok.py
uv lock -q && git init -q
env -u GITHUB_WORKSPACE /tmp/proof/bin/in-lockstep init >/dev/null
env -u GITHUB_WORKSPACE /tmp/proof/bin/in-lockstep provision > provision.txt
grep -q 'ran uv sync --locked' provision.txt
grep -q 'provision succeeded' provision.txt
listing=$(env -u GITHUB_WORKSPACE /tmp/proof/bin/in-lockstep ls)
grep -q '/tmp/proof-provision/.venv/bin/python' <<<"$listing"
env -u GITHUB_WORKSPACE /tmp/proof/bin/in-lockstep run selfcheck --paths . > selfcheck.txt
grep -q 'validate succeeded' selfcheck.txt
grep -q 'test succeeded' selfcheck.txt
echo "the shipped prompts and corpus survived packaging, selfcheck ran the repository's tools, and provision built them"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: distributions
path: dist/
if-no-files-found: error
publish:
name: Publish in-lockstep
needs: build
if: startsWith(github.ref, 'refs/tags/v') || inputs.publish
runs-on: ubuntu-24.04
# Must match the environment recorded in the project's publisher on PyPI, or the exchange is
# refused.
environment: pypi-in-lockstep
permissions:
id-token: write # mint the OIDC credential PyPI verifies; nothing else is needed
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: distributions
path: dist
- name: Upload to PyPI
# v1.14.2 or newer, and the floor is load-bearing rather than hygiene: it is the first
# release carrying Twine 7, and Twine below that rejects core metadata 2.5 outright. This
# distribution is 2.5, because hatchling emits PEP 639 `License-Expression` for the
# `license = "Apache-2.0"` the manifest declares.
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
packages-dir: dist/in-lockstep
# A re-run of a tag that already published is a normal thing to do; a version already on
# PyPI is then not a failure.
skip-existing: true
print-hash: true
release:
name: Create the GitHub release
needs: publish
# Tags only. A `workflow_dispatch` publish has no tag to hang a page on, and a release page
# without a tag would name a version nobody can check out.
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-24.04
# `contents: write` is what creating a release costs, and it is the one grant the publish job
# must never hold: a job that can mint the PyPI credential and also write the repository is
# the combination the trampoline's two-job split exists to refuse. So this is a third job,
# holding no OIDC grant in return, and neither of the other two can do what it does.
permissions:
contents: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: distributions
path: dist
- name: The release page names the version and carries the files PyPI got
# Exists because "Latest" drifted: until 2026-09-02 the repository's latest release was
# `actions-v0.1.1`, a compiler-line tag, because nothing made a page for 0.2.0 or 0.2.1
# and the pre-pivot pages were still there. Notes are generated from the merged pull
# requests, which Conventional Commits make readable, under an install line that does not
# change. Re-running a tag that already has its page is normal and not a failure, for the
# same reason `skip-existing` is set above.
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
tag="$GITHUB_REF_NAME"
if gh release view "$tag" >/dev/null 2>&1; then
echo "release $tag already exists; leaving it as it is"
exit 0
fi
notes=$(cat <<'NOTES'
```
uv tool install 'in-lockstep[anthropic]'
```
Provider extras: `anthropic`, `openai`, `google`, `bedrock`, `all`. Python 3.11 or newer.
NOTES
)
gh release create "$tag" dist/in-lockstep/* \
--verify-tag \
--title "in-lockstep ${tag#v}" \
--notes "$notes" \
--generate-notes