Skip to content

Commit 5bb59ef

Browse files
committed
The release path is the finder's: every green push to main publishes over trusted publishing with a derived version, and the wheel stops shipping the test suite
1 parent 4b0a754 commit 5bb59ef

4 files changed

Lines changed: 135 additions & 13 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: Deploy
2+
3+
on:
4+
push:
5+
branches: [main]
6+
7+
jobs:
8+
test:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
13+
- uses: actions/setup-python@v5
14+
with:
15+
python-version: "3.12"
16+
17+
- uses: astral-sh/setup-uv@v5
18+
19+
- name: Install
20+
run: uv pip install --system -e ".[dev]"
21+
22+
- name: Run tests
23+
run: pytest
24+
25+
# The supported install. **Every green push to `main` is a release** — the version is
26+
# derived here, not committed, because PyPI numbers are single-use and permanent.
27+
#
28+
# This is the finder's rule, and it is here for the reason it went there: `0.1.0` was
29+
# tagged by hand on that side, twelve commits landed behind it, and `uvx openoutreach`
30+
# kept serving the version from before all of them. A release nobody has to remember
31+
# cannot drift. The cost is accepted: every commit that passes CI is public and
32+
# permanent, so `main` is the release branch and `needs: test` is the whole gate.
33+
publish-pypi:
34+
if: github.ref == 'refs/heads/main'
35+
needs: test
36+
runs-on: ubuntu-latest
37+
# Required: the PyPI trusted publisher is registered against this workflow filename
38+
# *and* this environment name. Do not add a required reviewer to it — that would put
39+
# every push behind a manual approval, which is the thing this job exists to remove.
40+
environment: pypi
41+
permissions:
42+
id-token: write # PyPI trusted publishing
43+
steps:
44+
- uses: actions/checkout@v4
45+
with:
46+
fetch-depth: 0 # the version is a commit count; a shallow clone cannot count
47+
48+
# `0.1.0` in pyproject.toml is the *base*: major and minor are declared there and
49+
# bumped by hand when the change deserves it; the patch is how many commits this
50+
# repo has. Monotonic across a base bump (0.1.20 → 0.2.21), unique per commit, and
51+
# nothing has to be committed for it. The finder counts from its `v0.1.0` tag; this
52+
# repo has no tags, so the count starts at the first commit.
53+
- name: Derive the version from the commit count
54+
id: version
55+
run: |
56+
set -euo pipefail
57+
base=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2 | cut -d. -f1,2)
58+
patch=$(git rev-list --count HEAD)
59+
version="${base}.${patch}"
60+
echo "Releasing ${version}"
61+
sed -i "0,/^version = .*/s//version = \"${version}\"/" pyproject.toml
62+
echo "value=${version}" >> "$GITHUB_OUTPUT"
63+
64+
- name: Build sdist + wheel
65+
run: pipx run build
66+
67+
- name: Publish to PyPI
68+
uses: pypa/gh-action-pypi-publish@release/v1
69+
with:
70+
skip-existing: true # a re-run of an already-published commit is a no-op

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,15 @@ terminal**; headless, whatever is still missing is one error naming every variab
5353
answered it. The mailbox is stored only once its credentials pass an SMTP login, because the provider
5454
has no health API and that login is the only gate there is.
5555

56-
Still open: `pip install openoutreach[send]`, which can only be declared once this distribution is
57-
published.
56+
**Releases are every green push to `main`** (`.github/workflows/deploy.yml`): tests, then a build and a
57+
PyPI upload over trusted publishing, with the version derived from the commit count rather than
58+
committed — the finder's rule, for the reason it went there, since a release nobody has to remember
59+
cannot drift. No token is stored anywhere; the publisher is registered against the workflow filename
60+
and the `pypi` environment, so neither may be renamed.
61+
62+
Still open: arming that (a PyPI pending publisher and the `pypi` environment are two browser steps),
63+
and then `pip install openoutreach[send]`, which can only be declared once this distribution is on
64+
PyPI.
5865

5966
## Tests
6067

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ Finder = "https://github.com/eracle/OpenOutreach"
8282

8383
[tool.hatch.build.targets.wheel]
8484
packages = ["cold_outreach"]
85+
# The suite lives *inside* the package (it imports `cold_outreach.tests.factories`), so
86+
# it ships with the wheel unless said otherwise. An installed sender has no use for it
87+
# and no dev extras to run it with; the sdist still carries it.
88+
exclude = ["cold_outreach/tests"]
8589

8690
[tool.black]
8791
skip_string_normalization = false

roadmap/p1-e2-outsend-ingest-and-packaging.md

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
`cold_outreach/first_run.py` collects the campaign's fields, the operator who signs the mail and the
1111
mailbox itself — environment first, terminal second, one error naming every variable when headless —
1212
and the box is stored only once its SMTP login succeeds. **Every test in the repo runs** — the five
13-
inherited files are ported and nothing is ignored. What is left is the `openoutreach[send]` extra,
14-
which cannot be declared until this distribution is published.
13+
inherited files are ported and nothing is ignored. **The release path is built too**: `deploy.yml`
14+
tests and then publishes on every green push to `main`, with the version derived from the commit
15+
count, exactly as the finder does it. What is left is arming it — a PyPI pending publisher and the
16+
`pypi` environment, two browser steps — and then the `openoutreach[send]` extra, which cannot be
17+
declared until this distribution is on PyPI.
1518
- **Priority:** High — every other card here describes code that cannot run yet.
1619
- **Effort:** Medium
1720
- **Area:** Packaging + ingest — the receiving end of
@@ -140,13 +143,43 @@ For anyone picking this up: what an operator can do today, and what they cannot.
140143
| `outsend init` | **Works** — the campaign's three fields, the operator, and a mailbox, from env or a TTY; runs implicitly at first send | `cold_outreach/first_run.py` |
141144
| **A mailbox** | **Works**`init` calls `create_verified`, so a stored box is one whose SMTP login succeeded; four transport variables cover a box that is not on Google | `cold_outreach/first_run.py` |
142145
| **The operator** | **Works** — the name that signs the mail and the BCC address, upserted onto the one active user `seller_full_name()` reads | `cold_outreach/core/operator.py` |
143-
| `pip install openoutreach[send]` | **Missing** — needs `openoutsend` published first ||
146+
| The release path | **Built, not yet armed** — tests then publish on every green push to `main`, version derived from the commit count | `.github/workflows/deploy.yml` |
147+
| `pip install openoutsend` from PyPI | **Waiting on the trusted publisher** — the name is free, the wheel builds, installs and ingests from a clean venv ||
148+
| `pip install openoutreach[send]` | **Missing** — needs `openoutsend` on PyPI first ||
149+
150+
## How this gets released — the finder's rule, on this side too
151+
152+
**Every green push to `main` publishes.** The version is *derived at publish time*, never committed:
153+
`pyproject.toml`'s `version` is the base (major.minor, bumped by hand) and the patch is this repo's
154+
commit count. That is not a preference — the finder tagged `v0.1.0` by hand, twelve commits of work
155+
landed behind it, and `uvx openoutreach` kept serving the version from before all of them. **A release
156+
nobody has to remember cannot drift.** The cost is accepted and is the same one: `main` is the release
157+
branch, and `needs: test` is the whole gate.
158+
159+
**No token exists anywhere.** Publishing is PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/)
160+
over GitHub's OIDC, so nothing is stored in the repo, in an Actions secret, or on a laptop. The
161+
trade-off is that the publisher is registered against a *workflow filename* and an *environment name*:
162+
`deploy.yml` and `pypi`. **Renaming either breaks the release**, and a required reviewer on the `pypi`
163+
environment would put every push behind a click, which is the thing this removes.
164+
165+
**Two things have to be done by hand, once, in a browser** — they are the only reason this is not
166+
already published:
167+
168+
1. **A pending publisher on PyPI** (Your projects → Publishing → *Add a pending publisher*, since the
169+
project does not exist yet): PyPI project `openoutsend`, owner `eracle`, repository `OpenOutSend`,
170+
workflow `deploy.yml`, environment `pypi`. The first successful run creates the project.
171+
2. **The `pypi` environment** in the repo's GitHub settings, with **no** required reviewer.
172+
173+
Until both exist, the `publish-pypi` job fails at the OIDC exchange and the `test` job still passes —
174+
a re-run publishes once they do.
144175

145176
## What is next, in order
146177

147-
1. **Publish `openoutsend`**, then declare the extra on the finder's side and grep that nothing under
148-
`openoutreach/` imports it.
149-
2. **Then the cards that were waiting on all of this** — bounce detection, the inbound silent skip,
178+
1. **Arm the release** — the two browser steps above, then a push, then `pip install openoutsend`
179+
from a clean environment to prove the artifact rather than the build.
180+
2. **Declare `openoutreach[send]`** on the finder's side, and grep that nothing under `openoutreach/`
181+
imports `openoutsend` — the dependency crosses one way only.
182+
3. **Then the cards that were waiting on all of this** — bounce detection, the inbound silent skip,
150183
and the plays that replace the one prompt template.
151184

152185
## Done when
@@ -180,7 +213,14 @@ For anyone picking this up: what an operator can do today, and what they cannot.
180213
asked and `""` an operator who declined one.)*
181214
- [x] `pip install openoutsend` puts `outsend` on the PATH with its own default SQLite store under
182215
`~/.openoutsend/`, and the settings module is this repo's rather than a host project's.
183-
*(Installable and running from a checkout; publishing to PyPI is what the extra below waits on.)*
216+
*(Proven from the built wheel in a clean venv, not just from a checkout: `outsend` on the PATH,
217+
the store migrated on first run, a piped record stored, exit 0. The wheel excludes
218+
`cold_outreach/tests` — an installed sender has no dev extras to run it with — and the sdist
219+
keeps it.)*
220+
- [ ] A push to `main` releases: tests, then a build and a trusted-publishing upload whose version
221+
nobody has to remember. *(`deploy.yml` is written and mirrors the finder's, down to the derived
222+
patch. **Not armed** — the PyPI pending publisher and the `pypi` environment are two browser
223+
steps nobody has done yet, and the job fails at the OIDC exchange until they are.)*
184224
- [x] The send path runs against this repo's own tests — there is no harness at all right now.
185225
*(pytest-django, factories for this side's models, **268 tests green and nothing ignored**. The
186226
five inherited files are ported: what they asserted against the finder's `disqualified` flag and
@@ -192,10 +232,11 @@ For anyone picking this up: what an operator can do today, and what they cannot.
192232

193233
## What the next slice is
194234

195-
**Publishing `openoutsend`.** Everything else on this card is done, and the extra on the finder's side
196-
is the last thing waiting on a distribution rather than on code: `openoutreach[send]` cannot name a
197-
package that is not on PyPI. Once it is published, declare the extra and grep that nothing under
198-
`openoutreach/` imports `openoutsend` — the dependency crosses one way only.
235+
**Arming the release.** Everything on this card that is code is done: the wheel builds, installs and
236+
ingests from a clean environment, and `deploy.yml` will publish it. What is left is not code at all —
237+
the pending publisher and the `pypi` environment, described above, which only a browser can create.
238+
Then the extra: `openoutreach[send]` cannot name a package that is not on PyPI, and once it can, the
239+
grep that nothing under `openoutreach/` imports `openoutsend` is what keeps the dependency one-way.
199240

200241
## Open questions
201242

0 commit comments

Comments
 (0)