labtrail 0.1.0__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.
Files changed (110) hide show
  1. labtrail-0.1.0/.forgejo/workflows/ci.yml +385 -0
  2. labtrail-0.1.0/.github/workflows/ci.yml +398 -0
  3. labtrail-0.1.0/.gitignore +231 -0
  4. labtrail-0.1.0/LICENSE +73 -0
  5. labtrail-0.1.0/PKG-INFO +185 -0
  6. labtrail-0.1.0/README.md +95 -0
  7. labtrail-0.1.0/docs/conf.py +51 -0
  8. labtrail-0.1.0/docs/design/plan.md +2 -0
  9. labtrail-0.1.0/docs/design/requirements.md +2 -0
  10. labtrail-0.1.0/docs/index.md +60 -0
  11. labtrail-0.1.0/docs/methodology/author-loop.md +119 -0
  12. labtrail-0.1.0/docs/methodology/check-contract.md +75 -0
  13. labtrail-0.1.0/docs/methodology/index.md +64 -0
  14. labtrail-0.1.0/docs/methodology/learner-loop.md +102 -0
  15. labtrail-0.1.0/docs/methodology/metadata.md +101 -0
  16. labtrail-0.1.0/docs/methodology/tree.md +70 -0
  17. labtrail-0.1.0/docs/plan.md +1387 -0
  18. labtrail-0.1.0/docs/reference/cli.md +272 -0
  19. labtrail-0.1.0/docs/reference/collected-document.md +75 -0
  20. labtrail-0.1.0/docs/reference/ctrf.md +96 -0
  21. labtrail-0.1.0/docs/reference/helpers.md +158 -0
  22. labtrail-0.1.0/docs/reference/skills.md +101 -0
  23. labtrail-0.1.0/docs/requirements.md +1113 -0
  24. labtrail-0.1.0/docs_methodology.md +6 -0
  25. labtrail-0.1.0/ivpm.yaml +19 -0
  26. labtrail-0.1.0/pyproject.toml +60 -0
  27. labtrail-0.1.0/setup.cfg +4 -0
  28. labtrail-0.1.0/src/labtrail/__init__.py +22 -0
  29. labtrail-0.1.0/src/labtrail/__main__.py +6 -0
  30. labtrail-0.1.0/src/labtrail/_version.py +24 -0
  31. labtrail-0.1.0/src/labtrail/check/__init__.py +73 -0
  32. labtrail-0.1.0/src/labtrail/check/_assertions.py +276 -0
  33. labtrail-0.1.0/src/labtrail/check/_normalize.py +121 -0
  34. labtrail-0.1.0/src/labtrail/check/_run.py +136 -0
  35. labtrail-0.1.0/src/labtrail/cli.py +101 -0
  36. labtrail-0.1.0/src/labtrail/collect.py +157 -0
  37. labtrail-0.1.0/src/labtrail/commands/__init__.py +0 -0
  38. labtrail-0.1.0/src/labtrail/commands/_common.py +66 -0
  39. labtrail-0.1.0/src/labtrail/commands/assert_.py +103 -0
  40. labtrail-0.1.0/src/labtrail/commands/check.py +124 -0
  41. labtrail-0.1.0/src/labtrail/commands/collect.py +47 -0
  42. labtrail-0.1.0/src/labtrail/commands/doctor.py +137 -0
  43. labtrail-0.1.0/src/labtrail/commands/list_.py +102 -0
  44. labtrail-0.1.0/src/labtrail/commands/lock.py +186 -0
  45. labtrail-0.1.0/src/labtrail/commands/open_.py +185 -0
  46. labtrail-0.1.0/src/labtrail/commands/propagate.py +178 -0
  47. labtrail-0.1.0/src/labtrail/commands/roster.py +96 -0
  48. labtrail-0.1.0/src/labtrail/commands/status.py +146 -0
  49. labtrail-0.1.0/src/labtrail/commands/validate.py +87 -0
  50. labtrail-0.1.0/src/labtrail/dev/__init__.py +0 -0
  51. labtrail-0.1.0/src/labtrail/dev/__main__.py +71 -0
  52. labtrail-0.1.0/src/labtrail/dev/gen_cli_docs.py +118 -0
  53. labtrail-0.1.0/src/labtrail/dev/gen_skill.py +455 -0
  54. labtrail-0.1.0/src/labtrail/discovery.py +203 -0
  55. labtrail-0.1.0/src/labtrail/doctor.py +358 -0
  56. labtrail-0.1.0/src/labtrail/errors.py +105 -0
  57. labtrail-0.1.0/src/labtrail/execute.py +231 -0
  58. labtrail-0.1.0/src/labtrail/git.py +316 -0
  59. labtrail-0.1.0/src/labtrail/metadata.py +403 -0
  60. labtrail-0.1.0/src/labtrail/model.py +213 -0
  61. labtrail-0.1.0/src/labtrail/propagate.py +543 -0
  62. labtrail-0.1.0/src/labtrail/report/__init__.py +0 -0
  63. labtrail-0.1.0/src/labtrail/report/console.py +173 -0
  64. labtrail-0.1.0/src/labtrail/report/ctrf.py +274 -0
  65. labtrail-0.1.0/src/labtrail/share/skills/labtrail/SKILL.md +218 -0
  66. labtrail-0.1.0/src/labtrail/share/skills/labtrail-authoring/SKILL.md +295 -0
  67. labtrail-0.1.0/src/labtrail/share/skills/labtrail-checks/SKILL.md +302 -0
  68. labtrail-0.1.0/src/labtrail/share/skills/labtrail-learner/SKILL.md +173 -0
  69. labtrail-0.1.0/src/labtrail/share/templates/course-SKILL.md +47 -0
  70. labtrail-0.1.0/src/labtrail/share/templates/lab-SKILL.md +38 -0
  71. labtrail-0.1.0/src/labtrail/share/templates/lab.yaml +29 -0
  72. labtrail-0.1.0/src/labtrail/share/templates/trail.yaml +35 -0
  73. labtrail-0.1.0/src/labtrail/skills.py +76 -0
  74. labtrail-0.1.0/src/labtrail/trail.py +450 -0
  75. labtrail-0.1.0/src/labtrail/validate.py +307 -0
  76. labtrail-0.1.0/src/labtrail/verdict.py +107 -0
  77. labtrail-0.1.0/src/labtrail/worktree.py +176 -0
  78. labtrail-0.1.0/src/labtrail.egg-info/PKG-INFO +185 -0
  79. labtrail-0.1.0/src/labtrail.egg-info/SOURCES.txt +108 -0
  80. labtrail-0.1.0/src/labtrail.egg-info/dependency_links.txt +1 -0
  81. labtrail-0.1.0/src/labtrail.egg-info/entry_points.txt +9 -0
  82. labtrail-0.1.0/src/labtrail.egg-info/requires.txt +5 -0
  83. labtrail-0.1.0/src/labtrail.egg-info/scm_file_list.json +103 -0
  84. labtrail-0.1.0/src/labtrail.egg-info/scm_version.json +8 -0
  85. labtrail-0.1.0/src/labtrail.egg-info/top_level.txt +1 -0
  86. labtrail-0.1.0/tests/__init__.py +0 -0
  87. labtrail-0.1.0/tests/acceptance/conftest.py +72 -0
  88. labtrail-0.1.0/tests/acceptance/test_v1_acceptance.py +586 -0
  89. labtrail-0.1.0/tests/conftest.py +66 -0
  90. labtrail-0.1.0/tests/data/ctrf.schema.json +713 -0
  91. labtrail-0.1.0/tests/integration/conftest.py +72 -0
  92. labtrail-0.1.0/tests/integration/test_check.py +173 -0
  93. labtrail-0.1.0/tests/integration/test_collect.py +163 -0
  94. labtrail-0.1.0/tests/integration/test_learner.py +428 -0
  95. labtrail-0.1.0/tests/integration/test_list.py +80 -0
  96. labtrail-0.1.0/tests/integration/test_propagate.py +350 -0
  97. labtrail-0.1.0/tests/integration/test_validate.py +382 -0
  98. labtrail-0.1.0/tests/integration/test_worktree_sharing.py +118 -0
  99. labtrail-0.1.0/tests/trailkit/__init__.py +34 -0
  100. labtrail-0.1.0/tests/trailkit/builder.py +599 -0
  101. labtrail-0.1.0/tests/unit/test_check_helpers.py +325 -0
  102. labtrail-0.1.0/tests/unit/test_ctrf.py +223 -0
  103. labtrail-0.1.0/tests/unit/test_discovery.py +115 -0
  104. labtrail-0.1.0/tests/unit/test_doc_conventions.py +243 -0
  105. labtrail-0.1.0/tests/unit/test_execute.py +166 -0
  106. labtrail-0.1.0/tests/unit/test_git.py +125 -0
  107. labtrail-0.1.0/tests/unit/test_metadata.py +330 -0
  108. labtrail-0.1.0/tests/unit/test_packaging.py +157 -0
  109. labtrail-0.1.0/tests/unit/test_trail.py +189 -0
  110. labtrail-0.1.0/tests/unit/test_verdict.py +77 -0
@@ -0,0 +1,385 @@
1
+ # fvutils/labtrail -- Forgejo Actions.
2
+ #
3
+ # COPY THIS AS A PAIR. Its counterpart is .github/workflows/ci.yml. Derived
4
+ # from dvkit/forgejo-ops templates/ci.forgejo.yml; see that repo's
5
+ # templates/README.md.
6
+ #
7
+ # The two files differ in the `authority ==` comparison, in the Forgejo runner
8
+ # constraints below, and in nothing else. If you are changing anything else,
9
+ # they are drifting.
10
+ #
11
+ # ---------------------------------------------------------------------------
12
+ # DECISION, 2026-08-30 (inherited from the estate template): publish jobs FAIL
13
+ # CLOSED on an unreachable switch.
14
+ #
15
+ # If https://dvkit.org/.well-known/release-authority.json cannot be fetched ON A
16
+ # TAG, the gate fails and nothing publishes. A release blocked by an outage is
17
+ # recoverable; a release published twice from two forges is not.
18
+ # ---------------------------------------------------------------------------
19
+ #
20
+ # FORGEJO RUNNER CONSTRAINTS -- all measured on this instance, not assumed.
21
+ # `runs-on: ubuntu-latest` maps to catthehacker/ubuntu:act-22.04.
22
+ #
23
+ # * Python is 3.10, and pip is 22.0.2 -- which PREDATES PEP 660, so
24
+ # `pip install -e .` fails outright on a pyproject-only project. Every job
25
+ # here opens with a venv whose pip has been upgraded, put on PATH so the
26
+ # steps below are textually identical to the GitHub side.
27
+ # * `python` unqualified DOES NOT EXIST. Use python3 (before the venv is on
28
+ # PATH; after it, `python` is the venv's).
29
+ # * NO THIRD-PARTY ACTIONS. A bare `uses:` resolves against code.forgejo.org,
30
+ # not github.com -- so actions/setup-python and pypa/gh-action-pypi-publish
31
+ # are unavailable. actions/checkout and forgejo/upload-artifact DO resolve.
32
+ # Publishing is plain twine, which also keeps GitHub off the critical path
33
+ # of a release.
34
+ # * 3.10 is labtrail's declared floor, so the single runner Python is the
35
+ # more interesting half of the GitHub matrix rather than a compromise.
36
+ #
37
+ # Trial-run any step you add before landing it:
38
+ # docker run --rm -v "$PWD:/w" -w /w catthehacker/ubuntu:act-22.04 bash -c '<steps>'
39
+ #
40
+ # LABTRAIL-SPECIFIC DIVERGENCE FROM THE TEMPLATE: the template stamps a version
41
+ # by `sed`-ing a `SUFFIX = ""` line in a version file. labtrail has no such
42
+ # file -- setuptools-scm derives the version from git. See "Stamp the build
43
+ # version" below, which diverges in the safer direction.
44
+
45
+ name: CI
46
+
47
+ on:
48
+ push:
49
+ branches: ['**']
50
+ tags: ['v*']
51
+ pull_request:
52
+ workflow_dispatch:
53
+ schedule:
54
+ - cron: '17 5 * * *'
55
+
56
+ jobs:
57
+ # ---------------------------------------------------------------- gate ----
58
+ gate:
59
+ # Release master switch. Identical to the GitHub side except for what the
60
+ # publish job compares the answer against. `if:` on a job cannot reference
61
+ # a step in its own job, so the switch is resolved here and consumed
62
+ # through needs.gate.outputs.authority.
63
+ name: Release authority
64
+ runs-on: ubuntu-latest
65
+ outputs:
66
+ authority: ${{ steps.resolve.outputs.authority }}
67
+ steps:
68
+ - id: resolve
69
+ # One line on purpose: a multi-line `python3 -c` has to start at column
70
+ # 0, which terminates the YAML block scalar and silently corrupts the
71
+ # workflow.
72
+ run: |
73
+ url=https://dvkit.org/.well-known/release-authority.json
74
+ if ! body=$(curl -fsS --max-time 10 "$url"); then
75
+ case "$GITHUB_REF" in
76
+ refs/tags/v*)
77
+ echo "::error::release-authority switch unreachable; refusing to publish"
78
+ exit 1 ;;
79
+ *)
80
+ echo "authority=none" >> "$GITHUB_OUTPUT"
81
+ echo "switch unreachable on a non-tag ref; nothing to release"
82
+ exit 0 ;;
83
+ esac
84
+ fi
85
+ auth=$(printf '%s' "$body" | python3 -c 'import json,os,sys; d=json.load(sys.stdin); r=os.environ["GITHUB_REPOSITORY"]; print(d["repos"].get(r) or d["orgs"].get(r.split("/")[0]) or d["default"])')
86
+ echo "release authority for $GITHUB_REPOSITORY: $auth"
87
+ echo "authority=$auth" >> "$GITHUB_OUTPUT"
88
+
89
+ # ---------------------------------------------------------------- test ----
90
+ test:
91
+ name: Test
92
+ if: github.event_name != 'schedule'
93
+ runs-on: ubuntu-latest
94
+ steps:
95
+ - uses: actions/checkout@v4
96
+ with:
97
+ # setuptools-scm needs the history, and so does the suite: every
98
+ # fixture builds a real repository.
99
+ fetch-depth: 0
100
+ - name: Python
101
+ run: |
102
+ python3 -m venv /tmp/venv
103
+ /tmp/venv/bin/pip install -q --upgrade pip
104
+ echo "/tmp/venv/bin" >> "$GITHUB_PATH"
105
+ - name: Install
106
+ run: pip install -e '.[dev]'
107
+ - name: doctor
108
+ # R6.2: doctor runs first so an environment failure is distinguishable
109
+ # from a content failure at a glance.
110
+ run: labtrail doctor
111
+ - name: Unit and integration tests
112
+ run: pytest tests/unit tests/integration -m 'not slow and not external' -q
113
+ - name: Acceptance tests
114
+ run: pytest tests/acceptance -m 'not external' -q
115
+ - uses: forgejo/upload-artifact@v4
116
+ if: always()
117
+ with:
118
+ name: ctrf
119
+ path: build/ctrf/
120
+ if-no-files-found: ignore
121
+
122
+ # --------------------------------------------------------- skill-sync ----
123
+ skill-sync:
124
+ # R3.3/D-7: the four agent skills are generated from the prose in docs/.
125
+ # Two copies of a convention drift, and the drift is invisible until an
126
+ # agent confidently follows the stale one. Regenerate and diff.
127
+ name: Skills match the prose
128
+ if: github.event_name != 'schedule'
129
+ runs-on: ubuntu-latest
130
+ steps:
131
+ - uses: actions/checkout@v4
132
+ with:
133
+ fetch-depth: 0
134
+ - name: Python
135
+ run: |
136
+ python3 -m venv /tmp/venv
137
+ /tmp/venv/bin/pip install -q --upgrade pip
138
+ echo "/tmp/venv/bin" >> "$GITHUB_PATH"
139
+ - run: pip install -e '.[dev]'
140
+ - run: labtrail-dev gen-skill
141
+ - run: git diff --exit-code -- src/labtrail/share/skills/
142
+
143
+ # ---------------------------------------------------- docs-conventions ----
144
+ docs-conventions:
145
+ # R2.22.1/R2.22.3: every documented start-point fully qualified, every
146
+ # worktree add carrying -b. Cheap grep; the failure mode is silent -- a
147
+ # documented command that quietly puts a learner on a published branch.
148
+ name: Documented git spellings
149
+ if: github.event_name != 'schedule'
150
+ runs-on: ubuntu-latest
151
+ steps:
152
+ - uses: actions/checkout@v4
153
+ with:
154
+ fetch-depth: 0
155
+ - name: Python
156
+ run: |
157
+ python3 -m venv /tmp/venv
158
+ /tmp/venv/bin/pip install -q --upgrade pip
159
+ echo "/tmp/venv/bin" >> "$GITHUB_PATH"
160
+ - run: pip install -e '.[dev]'
161
+ - run: pytest tests/unit/test_doc_conventions.py -q
162
+
163
+ # ---------------------------------------------------------------- docs ----
164
+ docs:
165
+ name: Documentation
166
+ if: github.event_name != 'schedule'
167
+ runs-on: ubuntu-latest
168
+ steps:
169
+ - uses: actions/checkout@v4
170
+ with:
171
+ fetch-depth: 0
172
+ - name: Python
173
+ run: |
174
+ python3 -m venv /tmp/venv
175
+ /tmp/venv/bin/pip install -q --upgrade pip
176
+ echo "/tmp/venv/bin" >> "$GITHUB_PATH"
177
+ - run: pip install -e '.[dev]' sphinx myst-parser
178
+ # -W so a broken reference fails the build. graphviz is *configured* in
179
+ # conf.py for sphinx-labtrail (M9) but no page in this repo uses a
180
+ # directive, so the binary is not installed here.
181
+ - run: sphinx-build -W -b html docs docs/_build/html
182
+ - uses: forgejo/upload-artifact@v4
183
+ with:
184
+ name: docs
185
+ path: docs/_build/html
186
+
187
+ # --------------------------------------------------------------- build ----
188
+ build:
189
+ name: Build and check the distribution
190
+ runs-on: ubuntu-latest
191
+ steps:
192
+ - uses: actions/checkout@v4
193
+ with:
194
+ fetch-depth: 0
195
+
196
+ - name: Check for internal identifiers in tracked files
197
+ # This repo is published to git.dvkit.org, so anything committed here
198
+ # is public. Internal host names, tailnet addresses and CGNAT-range IPs
199
+ # must not appear in tracked files -- if a build ever needs one, it
200
+ # belongs in a repository secret, which is masked in logs. This applies
201
+ # to documentation and comments, not just code.
202
+ run: |
203
+ pattern='perch-char\.ts\.net|\.ts\.net|a9550|100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\.[0-9]+\.[0-9]+'
204
+ if git grep -nIE "$pattern" -- . ':!.forgejo/workflows/ci.yml' ; then
205
+ echo "::error::internal identifier found in a tracked file (see matches above)"
206
+ exit 1
207
+ fi
208
+ echo "OK: no internal identifiers in tracked files"
209
+
210
+ - name: Python
211
+ run: |
212
+ python3 -m venv /tmp/venv
213
+ /tmp/venv/bin/pip install -q --upgrade pip
214
+ echo "/tmp/venv/bin" >> "$GITHUB_PATH"
215
+
216
+ - name: Stamp the build version
217
+ # Tag builds keep the clean number. EVERYTHING ELSE gets a PEP 440
218
+ # local version segment, which PyPI rejects outright -- so an
219
+ # accidental upload of a CI artifact fails at the tool rather than
220
+ # succeeding quietly.
221
+ #
222
+ # 0.1.dev4+fj.r12.g7775f37
223
+ # └─ WHICH FORGE. $GITHUB_RUN_ID is not unique across them:
224
+ # GitHub's is an 11-digit global counter, Forgejo's a
225
+ # per-repo index that restarts if the instance is
226
+ # rebuilt. The run id alone collides.
227
+ #
228
+ # DIVERGENCE FROM THE TEMPLATE: there is no version file to sed.
229
+ # setuptools-scm computes the public version from the tag history, and
230
+ # we append only the local segment. That is the safer direction --
231
+ # the base cannot disagree with the tag, because it is derived from it.
232
+ run: |
233
+ pip install --upgrade build twine setuptools-scm
234
+ case "$GITHUB_REF" in
235
+ refs/tags/*)
236
+ echo "tag build -- setuptools-scm will use the tag verbatim"
237
+ ;;
238
+ *)
239
+ # `python -m setuptools_scm` reads THIS project's configuration,
240
+ # so the base agrees with what the build itself will compute.
241
+ # A hand-rolled get_version() call does not: without pyproject's
242
+ # fallback_version it answers 0.1.dev1 where the build answers
243
+ # 0.0.1.dev1. Everything from '+' is the local segment setuptools-scm
244
+ # would have added; we replace it with ours.
245
+ public=$(python -m setuptools_scm | sed 's/+.*//')
246
+ stamp="${public}+fj.r${GITHUB_RUN_ID}.g$(git rev-parse --short=7 HEAD)"
247
+ echo "stamping $stamp"
248
+ echo "SETUPTOOLS_SCM_PRETEND_VERSION=$stamp" >> "$GITHUB_ENV"
249
+ ;;
250
+ esac
251
+
252
+ - name: Build sdist and wheel
253
+ run: |
254
+ rm -rf dist/
255
+ python -m build
256
+ twine check dist/*
257
+ ls -la dist/
258
+
259
+ - name: Smoke-test the wheel
260
+ # Against the built wheel in a clean venv, not the source tree: the
261
+ # repo suite puts src/ on sys.path, so it can never tell you whether
262
+ # what SHIPS works. R8.5 (the entry points) and R3.6 (the skills are
263
+ # inside the package) are exactly the things a packaging mistake
264
+ # breaks and no source-tree test would notice.
265
+ run: |
266
+ python3 -m venv /tmp/wheelenv
267
+ /tmp/wheelenv/bin/pip install -q --upgrade pip
268
+ /tmp/wheelenv/bin/pip install -q "$(ls dist/*.whl)"
269
+ cd /tmp # out of the source tree, so nothing shadows the wheel
270
+ /tmp/wheelenv/bin/labtrail --version
271
+ /tmp/wheelenv/bin/labtrail --help > /dev/null
272
+ /tmp/wheelenv/bin/labtrail-dev --help > /dev/null
273
+ /tmp/wheelenv/bin/python -c '
274
+ import os
275
+ from importlib.metadata import entry_points
276
+ eps = {e.name: e for e in entry_points(group="agent.skills")}
277
+ assert eps, "no agent.skills entry points in the installed wheel"
278
+ for name, ep in sorted(eps.items()):
279
+ (d,) = ep.load()()
280
+ assert os.path.basename(d) == name, (name, d)
281
+ assert os.path.isfile(os.path.join(d, "SKILL.md")), d
282
+ print("skill ok:", name)
283
+ '
284
+
285
+ - uses: forgejo/upload-artifact@v4
286
+ with:
287
+ name: dist-labtrail
288
+ path: dist/*
289
+ if-no-files-found: error
290
+
291
+ # -------------------------------------------------- forward-integration ----
292
+ forward-integration:
293
+ # R4.25: subtree propagation on a proposed change. A revision can rebase
294
+ # through every descendant with no conflict at all and leave one of them no
295
+ # longer testing anything; only re-running the invariant at each hop finds
296
+ # it.
297
+ name: Forward integration
298
+ if: github.event_name == 'pull_request'
299
+ runs-on: ubuntu-latest
300
+ steps:
301
+ - uses: actions/checkout@v4
302
+ with:
303
+ fetch-depth: 0
304
+ - name: Python
305
+ run: |
306
+ python3 -m venv /tmp/venv
307
+ /tmp/venv/bin/pip install -q --upgrade pip
308
+ echo "/tmp/venv/bin" >> "$GITHUB_PATH"
309
+ - run: pip install -e '.[dev]'
310
+ - run: pytest tests/integration/test_propagate.py -q
311
+
312
+ # ------------------------------------------------------------- nightly ----
313
+ nightly:
314
+ name: Nightly
315
+ if: github.event_name == 'schedule'
316
+ runs-on: ubuntu-latest
317
+ steps:
318
+ - uses: actions/checkout@v4
319
+ with:
320
+ fetch-depth: 0
321
+ - name: Python
322
+ run: |
323
+ python3 -m venv /tmp/venv
324
+ /tmp/venv/bin/pip install -q --upgrade pip
325
+ echo "/tmp/venv/bin" >> "$GITHUB_PATH"
326
+ - run: pip install -e '.[dev]'
327
+ - run: pytest -q -m 'slow or external or not slow'
328
+
329
+ # ------------------------------------------------------------- publish ----
330
+ publish:
331
+ name: Publish to PyPI
332
+ # A TAG, and the switch naming THIS forge. A push to a branch never
333
+ # releases anything -- that is what makes the 5-minute sync safe.
334
+ #
335
+ # SKIPS -- does not fail -- when the switch names GitHub.
336
+ if: startsWith(github.ref, 'refs/tags/v') && needs.gate.outputs.authority == 'forgejo'
337
+ needs: [gate, build, test]
338
+ runs-on: ubuntu-latest
339
+ steps:
340
+ - uses: actions/checkout@v4
341
+ with:
342
+ fetch-depth: 0
343
+
344
+ - uses: forgejo/download-artifact@v4
345
+ with:
346
+ name: dist-labtrail
347
+ path: dist
348
+
349
+ - name: Check the tag IS the version
350
+ # The template checks the tag against a version file and then the
351
+ # artifacts against that file. setuptools-scm removes the first half:
352
+ # the source version IS the tag, by construction, so there is no file
353
+ # that can disagree with it.
354
+ #
355
+ # The second half is not automatic and is what this step keeps. An
356
+ # artifact built from a shallow clone, from a dirty tree, or left over
357
+ # in dist/ from an earlier build carries a different version -- and a
358
+ # dirty tree in particular produces a LOCAL segment (`+d20260913`),
359
+ # which is precisely what must never reach PyPI.
360
+ run: |
361
+ tag="${GITHUB_REF#refs/tags/v}"
362
+ echo "tag=$tag"
363
+ for f in dist/*; do
364
+ case "$(basename "$f")" in
365
+ labtrail-${tag}-py3-none-any.whl|labtrail-${tag}.tar.gz) ;;
366
+ *) echo "::error::artifact $(basename "$f") is not version ${tag}"; exit 1 ;;
367
+ esac
368
+ done
369
+ echo "OK: the tag and every artifact agree on ${tag}"
370
+
371
+ - name: Publish
372
+ env:
373
+ TWINE_USERNAME: __token__
374
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
375
+ run: |
376
+ if [ -z "$TWINE_PASSWORD" ]; then
377
+ echo "::error::PYPI_API_TOKEN is not visible to this repository"
378
+ exit 1
379
+ fi
380
+ python3 -m venv /tmp/twineenv
381
+ /tmp/twineenv/bin/pip install -q --upgrade pip twine
382
+ /tmp/twineenv/bin/twine check dist/*
383
+ # Idempotence net: with one switch and two possible publishers, a
384
+ # double-fire must be a no-op rather than a failure or a corruption.
385
+ /tmp/twineenv/bin/twine upload --non-interactive --skip-existing dist/*