psyexp-core 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ name: publish
2
+
3
+ # Publishes psyexp-core to PyPI when a GitHub Release is published.
4
+ #
5
+ # Publishing is deliberately tied to the *Release* event, NOT to pushing a tag.
6
+ # A tag only runs release-check.yml (tests + version/changelog/lock checks, then a
7
+ # DRAFT Release), so tags can be created, moved, or deleted freely while iterating
8
+ # without ever touching PyPI. A version ships only when a human publishes the draft.
9
+ #
10
+ # Auth uses PyPI Trusted Publishing (OIDC) — no API token is stored as a secret.
11
+ # The `pypi` environment scopes the OIDC trust and gates publishes behind a
12
+ # required-reviewer approval. One-time setup: see docs/ci-setup.md.
13
+ on:
14
+ release:
15
+ types: [published]
16
+
17
+ jobs:
18
+ # Gate the release on the full test matrix (reuses tests.yml).
19
+ tests:
20
+ uses: ./.github/workflows/tests.yml
21
+
22
+ build:
23
+ needs: tests
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v5
27
+
28
+ - name: Install uv
29
+ uses: astral-sh/setup-uv@v6
30
+
31
+ # Never ship a wheel whose version disagrees with the release tag.
32
+ - name: Verify release tag matches pyproject version
33
+ run: |
34
+ set -euo pipefail
35
+ tag="${GITHUB_REF_NAME#v}"
36
+ version="$(uv version --short)"
37
+ echo "Release tag: $tag pyproject version: $version"
38
+ if [ "$tag" != "$version" ]; then
39
+ echo "::error::Release tag ($tag) does not match pyproject version ($version)."
40
+ exit 1
41
+ fi
42
+
43
+ - name: Build sdist + wheel
44
+ run: uv build
45
+
46
+ - uses: actions/upload-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist/
50
+
51
+ pypi:
52
+ needs: build
53
+ runs-on: ubuntu-latest
54
+ environment:
55
+ name: pypi
56
+ url: https://pypi.org/project/psyexp-core/
57
+ permissions:
58
+ id-token: write # mint the OIDC token for Trusted Publishing
59
+ steps:
60
+ - uses: actions/download-artifact@v4
61
+ with:
62
+ name: dist
63
+ path: dist/
64
+
65
+ - name: Publish to PyPI
66
+ uses: pypa/gh-action-pypi-publish@release/v1
67
+ with:
68
+ # Tolerate re-running a Release whose version is already on PyPI
69
+ # (PyPI versions are immutable): skip instead of hard-failing.
70
+ skip-existing: true
@@ -0,0 +1,112 @@
1
+ name: release-check
2
+
3
+ # On a version tag: run the test matrix, verify the version/changelog/lock all
4
+ # agree, and create a DRAFT GitHub Release with notes from CHANGELOG.md. Tagging
5
+ # does not publish anything — a human reviews and publishes the draft, which then
6
+ # triggers publish.yml (PyPI). See docs/releasing.md.
7
+ on:
8
+ push:
9
+ tags:
10
+ - "v*"
11
+
12
+ jobs:
13
+ # Tests must pass before we accept a release tag.
14
+ tests:
15
+ uses: ./.github/workflows/tests.yml
16
+
17
+ version-check:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v5
21
+
22
+ - name: Install uv
23
+ uses: astral-sh/setup-uv@v6
24
+
25
+ - name: Verify version bump and lockfile
26
+ run: |
27
+ set -euo pipefail
28
+
29
+ tag="${GITHUB_REF_NAME#v}"
30
+ echo "Tag version: $tag"
31
+
32
+ pyproject_version="$(uv version --short)"
33
+ echo "pyproject version: $pyproject_version"
34
+
35
+ # Project's own pinned version inside the lockfile.
36
+ lock_version="$(
37
+ awk '
38
+ /^name = "psyexp-core"$/ { found = 1; next }
39
+ found && /^version = / {
40
+ sub(/^version = "/, ""); sub(/"$/, ""); print; exit
41
+ }
42
+ ' uv.lock
43
+ )"
44
+ echo "uv.lock version: $lock_version"
45
+
46
+ if [ "$pyproject_version" != "$tag" ]; then
47
+ echo "::error::pyproject.toml version ($pyproject_version) does not match tag ($tag). Bump the version before tagging."
48
+ exit 1
49
+ fi
50
+
51
+ if [ "$lock_version" != "$tag" ]; then
52
+ echo "::error::uv.lock version ($lock_version) does not match tag ($tag). Run 'uv lock' and commit the result."
53
+ exit 1
54
+ fi
55
+
56
+ # Ensure the lockfile is fully consistent with pyproject.toml
57
+ # (catches dependency drift as well as a stale self-version).
58
+ uv lock --check
59
+
60
+ # CHANGELOG.md must document this version (the draft Release notes are
61
+ # extracted from it below).
62
+ if ! awk -v ver="$tag" '
63
+ /^## / { heading = $0; gsub(/[][]/, "", heading); if (index(heading, ver)) found = 1 }
64
+ END { exit found ? 0 : 1 }
65
+ ' CHANGELOG.md; then
66
+ echo "::error::CHANGELOG.md has no section for $tag. Add a '## v$tag' entry before tagging."
67
+ exit 1
68
+ fi
69
+
70
+ echo "Version $tag is consistent across pyproject.toml, uv.lock, and CHANGELOG.md."
71
+
72
+ # After the gates pass, draft a GitHub Release with notes from CHANGELOG.md.
73
+ # The draft is NOT published, so nothing reaches PyPI until a human publishes it.
74
+ draft-release:
75
+ needs: [tests, version-check]
76
+ runs-on: ubuntu-latest
77
+ permissions:
78
+ contents: write
79
+ steps:
80
+ - uses: actions/checkout@v5
81
+
82
+ # Pull the section for this tag out of CHANGELOG.md: the heading containing
83
+ # the version (without the leading "v") through to the next "## " heading.
84
+ - name: Extract release notes
85
+ id: notes
86
+ run: |
87
+ version="${GITHUB_REF_NAME#v}"
88
+ notes_file="$(mktemp)"
89
+ awk -v ver="$version" '
90
+ $0 ~ "^## " {
91
+ if (found) exit
92
+ heading = $0
93
+ gsub(/[][]/, "", heading)
94
+ if (index(heading, ver)) { found = 1; next }
95
+ }
96
+ found { print }
97
+ ' CHANGELOG.md > "$notes_file"
98
+
99
+ if [ ! -s "$notes_file" ]; then
100
+ printf 'Release %s\n' "$GITHUB_REF_NAME" > "$notes_file"
101
+ fi
102
+
103
+ echo "file=$notes_file" >> "$GITHUB_OUTPUT"
104
+
105
+ - name: Create draft GitHub Release
106
+ uses: softprops/action-gh-release@v2
107
+ with:
108
+ name: ${{ github.ref_name }}
109
+ body_path: ${{ steps.notes.outputs.file }}
110
+ draft: true
111
+ # Mark as prerelease when the tag carries an rc/alpha/beta suffix.
112
+ prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') }}
@@ -0,0 +1,44 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.11", "3.12", "3.13"]
16
+
17
+ steps:
18
+ - uses: actions/checkout@v5
19
+
20
+ - name: Install uv
21
+ uses: astral-sh/setup-uv@v6
22
+ with:
23
+ enable-cache: true
24
+
25
+ - name: Set up Python ${{ matrix.python-version }}
26
+ run: uv python install ${{ matrix.python-version }}
27
+
28
+ # The tested surface (manifest, recording, diagnostics, rundir) is
29
+ # PsychoPy-free, so we skip the heavy GL/PsychoPy stack and install the
30
+ # project without its runtime deps plus the dev tooling only.
31
+ - name: Install
32
+ run: |
33
+ uv venv --python ${{ matrix.python-version }}
34
+ uv pip install --no-deps -e .
35
+ uv pip install pytest ruff
36
+
37
+ # Use --no-sync so `uv run` does not re-resolve and install the full
38
+ # dependency tree (which includes PsychoPy/wxPython and fails to build on
39
+ # CI). We rely on the no-deps venv set up above instead.
40
+ - name: Lint
41
+ run: uv run --no-sync ruff check .
42
+
43
+ - name: Test
44
+ run: uv run --no-sync pytest
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ *.egg-info/
7
+ build/
8
+ dist/
9
+ .DS_Store
10
+ .idea/
@@ -0,0 +1,68 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ See [docs/releasing.md](docs/releasing.md) for the release process.
8
+
9
+ ## v0.5.1
10
+
11
+ ### Added
12
+
13
+ - PyPI publishing on a published GitHub Release via Trusted Publishing (OIDC; no
14
+ stored token).
15
+ - Draft GitHub Release creation on tag push, with notes pulled from this file.
16
+ - This changelog and a release guide ([docs/releasing.md](docs/releasing.md)).
17
+ - `[project.urls]` for the PyPI project page.
18
+
19
+ ## v0.5.0
20
+
21
+ ### Added
22
+
23
+ - Timed-press + keyboard-clock API in `keyboard`: `get_presses` (name + reaction
24
+ time), `reset_clock_on_flip`, `reset_clock`, `clock_time`, and the `KeyPress`
25
+ dataclass — so timing-critical response windows can read frame-accurate RT
26
+ through the shared abstraction.
27
+
28
+ ### Fixed
29
+
30
+ - `keyboard` imports PsychoPy lazily, so the module (and its PsychoPy-free
31
+ timed-press / clock helpers) stays importable and unit-testable in headless/CI
32
+ environments without the PsychoPy stack.
33
+
34
+ ## v0.4.0
35
+
36
+ ### Changed
37
+
38
+ - Instruction-pager page type uses a generic, so callers keep their own page type.
39
+
40
+ ### Added
41
+
42
+ - Makefile for release automation.
43
+
44
+ ## v0.3.0
45
+
46
+ ### Added
47
+
48
+ - Loud warning when psychtoolbox is unavailable and the keyboard falls back to
49
+ PsychoPy's focus-dependent `event` backend.
50
+
51
+ ## v0.2.0
52
+
53
+ ### Fixed
54
+
55
+ - Poll for keyboard input rather than blocking, so the window keeps flipping (and
56
+ can come to the foreground to receive keys) on macOS.
57
+
58
+ ### Added
59
+
60
+ - Co-development instructions for overlaying an editable checkout over a git pin.
61
+
62
+ ## v0.1.0
63
+
64
+ Initial release: the task-agnostic harness — `screen` (fullscreen window +
65
+ frame-timing calibration), `diagnostics`, `rundir`, `manifest`, `recording`
66
+ (`CsvWriter`), `wizard` setup primitives, the `instructions` pager, and the
67
+ `keyboard` PTB/event abstraction — with the version resolved from `pyproject.toml`
68
+ and CI for tests and release checks.
@@ -0,0 +1,30 @@
1
+ .PHONY: release bump-patch bump-minor bump-major version
2
+
3
+ # Current version, read straight from pyproject.toml.
4
+ VERSION := $(shell grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
5
+
6
+ version:
7
+ @echo $(VERSION)
8
+
9
+ # Cut a release: bump pyproject.toml to $(TO), commit, and tag v$(TO).
10
+ # Usage: make release TO=0.4.0
11
+ release:
12
+ @test -n "$(TO)" || { echo "usage: make release TO=X.Y.Z"; exit 1; }
13
+ @test -z "$$(git status --porcelain)" || { echo "working tree is dirty; commit or stash first"; exit 1; }
14
+ @git rev-parse -q --verify "refs/tags/v$(TO)" >/dev/null && { echo "tag v$(TO) already exists"; exit 1; } || true
15
+ sed -i '' 's/^version = ".*"/version = "$(TO)"/' pyproject.toml
16
+ uv lock
17
+ git add pyproject.toml uv.lock
18
+ git commit -m "chore: release v$(TO)"
19
+ git tag -a "v$(TO)" -m "v$(TO)"
20
+ @echo "tagged v$(TO) — run 'git push && git push --tags' to publish"
21
+
22
+ # Convenience wrappers that compute the next version from the current one.
23
+ bump-patch:
24
+ @$(MAKE) release TO=$(shell echo $(VERSION) | awk -F. '{printf "%d.%d.%d", $$1, $$2, $$3+1}')
25
+
26
+ bump-minor:
27
+ @$(MAKE) release TO=$(shell echo $(VERSION) | awk -F. '{printf "%d.%d.0", $$1, $$2+1}')
28
+
29
+ bump-major:
30
+ @$(MAKE) release TO=$(shell echo $(VERSION) | awk -F. '{printf "%d.0.0", $$1+1}')
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: psyexp-core
3
+ Version: 0.5.1
4
+ Summary: Task-agnostic harness for PsychoPy experiments: screen/frame-timing setup, run manifests, CSV writers, setup-wizard primitives, instruction pager, and keyboard abstraction.
5
+ Project-URL: Homepage, https://github.com/HAPNlab/psyexp-core
6
+ Project-URL: Repository, https://github.com/HAPNlab/psyexp-core
7
+ Author: Eric Wang
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: prompt-toolkit>=3.0
10
+ Requires-Dist: psychopy>=2025.1
11
+ Requires-Dist: pyobjc-framework-quartz>=10; sys_platform == 'darwin'
12
+ Requires-Dist: questionary>=2.0
13
+ Requires-Dist: rich>=14.3.3
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Requires-Dist: ruff>=0.4; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # psyexp-core
20
+
21
+ Task-agnostic harness for PsychoPy experiments, shared across the lab's task
22
+ repos (`heat-task`, `mid-task`, `mid-task-deterministic`). It owns the *plumbing*
23
+ that every task duplicates; each task repo keeps only its own stimuli, trial
24
+ logic, and record schemas.
25
+
26
+ ## What's in here
27
+
28
+ | Module | Responsibility |
29
+ | --- | --- |
30
+ | `screen` | `setup_screen()` — open a fullscreen PsychoPy window, enable VSYNC, run a frame-timing calibration, and return a `ScreenDiagnostics`. |
31
+ | `diagnostics` | The `ScreenDiagnostics` dataclass (import-light; no PsychoPy). |
32
+ | `rundir` | `make_run_dir(data_dir, label, session_time)` — timestamped output directory. |
33
+ | `manifest` | `write_manifest(...)` + `system_info()` — JSON run manifest with system/display/process diagnostics and the resolved `psyexp_core_version`. App-specific fields are injected via `header` / `study_params`. |
34
+ | `recording` | `CsvWriter` base class (maps a dataclass record onto a fixed column schema). |
35
+ | `wizard` | questionary / prompt_toolkit setup-wizard primitives: shared styles, `ask_text` / `ask_select` / `ask_confirm`, `PosFloatValidator`, `prompt_unique_name`, `quit_app`. |
36
+ | `instructions` | `page_through(...)` — a self-paced, keypress-driven instruction pager. |
37
+ | `keyboard` | PTB / PsychoPy-event keyboard abstraction: `build_keyboard` / `get_keys` / `wait_for_keys` / `clear_events`, plus the timed-press API for response windows — `get_presses` (name + rt), `reset_clock_on_flip` / `reset_clock` / `clock_time`. |
38
+
39
+ ## Use from a task repo
40
+
41
+ Add it as a dependency. For day-to-day development, point at a local checkout so
42
+ edits are live without reinstalling:
43
+
44
+ ```toml
45
+ # your-task/pyproject.toml
46
+ dependencies = ["psyexp-core"]
47
+
48
+ [tool.uv.sources]
49
+ psyexp-core = { path = "../psyexp-core", editable = true }
50
+ ```
51
+
52
+ For a reproducible release build, pin a tagged ref instead:
53
+
54
+ ```toml
55
+ [tool.uv.sources]
56
+ psyexp-core = { git = "ssh://git@github.com/<you>/psyexp-core.git", tag = "v0.1.0" }
57
+ ```
58
+
59
+ `write_manifest` records the resolved `psyexp_core_version` so each run is
60
+ traceable back to a core version.
61
+
62
+ ### Co-developing core while a task repo keeps the git pin
63
+
64
+ Lab task repos (e.g. `heat-task`) commit the **git-tag** source above so clones
65
+ reproduce exactly, then overlay a local editable install for development:
66
+
67
+ ```bash
68
+ uv pip install -e ../psyexp-core
69
+ ```
70
+
71
+ **Gotcha:** `uv run` re-syncs the task venv from its `uv.lock` on every launch,
72
+ which reverts that editable install straight back to the pinned tag (symptoms:
73
+ your local core edits silently don't take effect). Set `UV_NO_SYNC=1` in the task
74
+ repo (export it in your shell, or use `uv run --no-sync`) so the editable overlay
75
+ sticks; run a manual `uv sync` only when you change other deps, then re-run the
76
+ editable install. See heat-task's README ("Co-developing `psyexp-core` locally")
77
+ for the full workflow.
78
+
79
+ ## Releasing
80
+
81
+ Tagging and publishing are deliberately separate, so tags stay cheap to iterate on:
82
+
83
+ 1. **Bump + lock + changelog**, then tag `vX.Y.Z`. The tag runs the checks and
84
+ creates a **draft** GitHub Release — it does **not** publish anything.
85
+ 2. **Review the draft** Release and publish it. That triggers
86
+ [`publish.yml`](.github/workflows/publish.yml), which uploads to
87
+ [PyPI](https://pypi.org/project/psyexp-core/) via **Trusted Publishing** (OIDC;
88
+ no API token stored).
89
+
90
+ PyPI versions are immutable, so retagging never republishes; bump the version to
91
+ ship new code. See **[docs/releasing.md](docs/releasing.md)** for the full process,
92
+ SemVer policy, pre-releases, retag semantics, and the one-time PyPI setup.
@@ -0,0 +1,74 @@
1
+ # psyexp-core
2
+
3
+ Task-agnostic harness for PsychoPy experiments, shared across the lab's task
4
+ repos (`heat-task`, `mid-task`, `mid-task-deterministic`). It owns the *plumbing*
5
+ that every task duplicates; each task repo keeps only its own stimuli, trial
6
+ logic, and record schemas.
7
+
8
+ ## What's in here
9
+
10
+ | Module | Responsibility |
11
+ | --- | --- |
12
+ | `screen` | `setup_screen()` — open a fullscreen PsychoPy window, enable VSYNC, run a frame-timing calibration, and return a `ScreenDiagnostics`. |
13
+ | `diagnostics` | The `ScreenDiagnostics` dataclass (import-light; no PsychoPy). |
14
+ | `rundir` | `make_run_dir(data_dir, label, session_time)` — timestamped output directory. |
15
+ | `manifest` | `write_manifest(...)` + `system_info()` — JSON run manifest with system/display/process diagnostics and the resolved `psyexp_core_version`. App-specific fields are injected via `header` / `study_params`. |
16
+ | `recording` | `CsvWriter` base class (maps a dataclass record onto a fixed column schema). |
17
+ | `wizard` | questionary / prompt_toolkit setup-wizard primitives: shared styles, `ask_text` / `ask_select` / `ask_confirm`, `PosFloatValidator`, `prompt_unique_name`, `quit_app`. |
18
+ | `instructions` | `page_through(...)` — a self-paced, keypress-driven instruction pager. |
19
+ | `keyboard` | PTB / PsychoPy-event keyboard abstraction: `build_keyboard` / `get_keys` / `wait_for_keys` / `clear_events`, plus the timed-press API for response windows — `get_presses` (name + rt), `reset_clock_on_flip` / `reset_clock` / `clock_time`. |
20
+
21
+ ## Use from a task repo
22
+
23
+ Add it as a dependency. For day-to-day development, point at a local checkout so
24
+ edits are live without reinstalling:
25
+
26
+ ```toml
27
+ # your-task/pyproject.toml
28
+ dependencies = ["psyexp-core"]
29
+
30
+ [tool.uv.sources]
31
+ psyexp-core = { path = "../psyexp-core", editable = true }
32
+ ```
33
+
34
+ For a reproducible release build, pin a tagged ref instead:
35
+
36
+ ```toml
37
+ [tool.uv.sources]
38
+ psyexp-core = { git = "ssh://git@github.com/<you>/psyexp-core.git", tag = "v0.1.0" }
39
+ ```
40
+
41
+ `write_manifest` records the resolved `psyexp_core_version` so each run is
42
+ traceable back to a core version.
43
+
44
+ ### Co-developing core while a task repo keeps the git pin
45
+
46
+ Lab task repos (e.g. `heat-task`) commit the **git-tag** source above so clones
47
+ reproduce exactly, then overlay a local editable install for development:
48
+
49
+ ```bash
50
+ uv pip install -e ../psyexp-core
51
+ ```
52
+
53
+ **Gotcha:** `uv run` re-syncs the task venv from its `uv.lock` on every launch,
54
+ which reverts that editable install straight back to the pinned tag (symptoms:
55
+ your local core edits silently don't take effect). Set `UV_NO_SYNC=1` in the task
56
+ repo (export it in your shell, or use `uv run --no-sync`) so the editable overlay
57
+ sticks; run a manual `uv sync` only when you change other deps, then re-run the
58
+ editable install. See heat-task's README ("Co-developing `psyexp-core` locally")
59
+ for the full workflow.
60
+
61
+ ## Releasing
62
+
63
+ Tagging and publishing are deliberately separate, so tags stay cheap to iterate on:
64
+
65
+ 1. **Bump + lock + changelog**, then tag `vX.Y.Z`. The tag runs the checks and
66
+ creates a **draft** GitHub Release — it does **not** publish anything.
67
+ 2. **Review the draft** Release and publish it. That triggers
68
+ [`publish.yml`](.github/workflows/publish.yml), which uploads to
69
+ [PyPI](https://pypi.org/project/psyexp-core/) via **Trusted Publishing** (OIDC;
70
+ no API token stored).
71
+
72
+ PyPI versions are immutable, so retagging never republishes; bump the version to
73
+ ship new code. See **[docs/releasing.md](docs/releasing.md)** for the full process,
74
+ SemVer policy, pre-releases, retag semantics, and the one-time PyPI setup.
@@ -0,0 +1,100 @@
1
+ # Setting up PyPI CI (one-time)
2
+
3
+ This is the **one-time** setup that lets [`publish.yml`](../.github/workflows/publish.yml)
4
+ upload `psyexp-core` to PyPI. For the day-to-day release process (bump, tag,
5
+ publish the draft), see [releasing.md](releasing.md).
6
+
7
+ ## How auth works: Trusted Publishing (OIDC), no stored token
8
+
9
+ We do **not** store a PyPI API token anywhere — not as a repo secret, not as an
10
+ environment secret. Instead the publish job uses **Trusted Publishing**: at run
11
+ time GitHub mints a short-lived OIDC token, and PyPI accepts the upload because it
12
+ matches a *trusted publisher* you registered (owner + repo + workflow + environment).
13
+
14
+ Why this is the safest option:
15
+
16
+ - There's no long-lived credential to leak — the token exists only for minutes,
17
+ inside the one publish job.
18
+ - The token is scoped to the `pypi` **environment**, so only a run in that
19
+ environment can mint it — not test/PR/push workflows.
20
+ - The `pypi` environment adds a **manual approval gate** before the job runs.
21
+
22
+ The publish job already declares this (no edits needed):
23
+
24
+ ```yaml
25
+ # .github/workflows/publish.yml
26
+ permissions:
27
+ id-token: write # mint the OIDC token
28
+ environment:
29
+ name: pypi # gate + scope
30
+ ```
31
+
32
+ ## Step 1 — Create the `pypi` GitHub Environment + approval gate
33
+
34
+ GitHub auto-creates an environment on first use *without* protections, so this step
35
+ is only needed to add the reviewer gate (recommended).
36
+
37
+ **UI:** Repo → **Settings → Environments → New environment** → name it `pypi`.
38
+ Then under **Deployment protection rules**:
39
+
40
+ - Enable **Required reviewers** and add yourself (and/or a `@HAPNlab` team). The
41
+ publish job will now pause and wait for an approval click before it runs.
42
+ - *(Optional)* **Deployment branches and tags** → *Selected* → add a tag rule like
43
+ `v*` so only version tags can deploy to `pypi`.
44
+
45
+ **CLI alternative** (needs repo admin; reviewer IDs are numeric user/team IDs):
46
+
47
+ ```sh
48
+ # create/configure the environment with a required reviewer
49
+ gh api -X PUT repos/HAPNlab/psyexp-core/environments/pypi \
50
+ -F "reviewers[][type]=User" -F "reviewers[][id]=<YOUR_USER_ID>"
51
+ # look up your numeric id:
52
+ gh api users/<your-login> --jq .id
53
+ ```
54
+
55
+ ## Step 2 — Register the trusted publisher on PyPI
56
+
57
+ This tells PyPI to trust uploads from this repo's publish workflow.
58
+
59
+ ### If the project does **not** exist on PyPI yet (first release)
60
+
61
+ Use a **pending publisher** — it creates the project on first successful upload:
62
+
63
+ 1. Log in to PyPI → **Account settings → Publishing** (or
64
+ <https://pypi.org/manage/account/publishing/>).
65
+ 2. Under **Add a new pending publisher**, fill in:
66
+ - **PyPI Project Name:** `psyexp-core`
67
+ - **Owner:** `HAPNlab`
68
+ - **Repository name:** `psyexp-core`
69
+ - **Workflow name:** `publish.yml`
70
+ - **Environment name:** `pypi`
71
+ 3. Save.
72
+
73
+ ### If the project already exists
74
+
75
+ Project page → **Manage → Publishing → Add a new publisher**, with the same
76
+ owner / repo / workflow / environment values above.
77
+
78
+ > The **Environment name must be exactly `pypi`** — it has to match the
79
+ > `environment: name:` in `publish.yml`, or PyPI will reject the OIDC token.
80
+
81
+ ## Step 3 — Do a release
82
+
83
+ Follow [releasing.md](releasing.md): bump the version + `CHANGELOG.md`, `uv lock`,
84
+ tag `vX.Y.Z`. The tag drafts a GitHub Release; **publish the draft**. That triggers
85
+ `publish.yml`, which will:
86
+
87
+ 1. run the test matrix and build the sdist/wheel, then
88
+ 2. pause on the `pypi` environment for your **approval**, then
89
+ 3. upload to PyPI via OIDC.
90
+
91
+ Approve it from the **Actions** run page (or the repo's **Environments** view).
92
+
93
+ ## Troubleshooting
94
+
95
+ | Symptom | Cause / fix |
96
+ |---|---|
97
+ | `invalid-publisher` / OIDC rejected | The PyPI trusted publisher's owner/repo/workflow/**environment** don't all match. Re-check Step 2 — `pypi` and `publish.yml` are the usual culprits. |
98
+ | Job runs but never uploads / waits forever | It's parked on the environment approval gate. Approve it on the run page. |
99
+ | `File already exists` | That version is already on PyPI (versions are immutable). `skip-existing: true` turns this into a skip; bump the version to ship new code. See [releasing.md](releasing.md#retag--re-release-semantics). |
100
+ | `id-token` permission error | The job needs `permissions: id-token: write` (already set in `publish.yml`). |