You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Plan 034's T101, "T099's release step" (with openDox-code#79): a release workflow that publishes openDox to PyPI by trusted publishing (OIDC). It makes release 1's ruled install line, pip install "opendox[local]", work as written (#1144 10.3, as T007's batch H addendum reads, R1Q15 (b) and R1Q16 (iii), RULED 5850003126). Today https://pypi.org/pypi/opendox/json and https://test.pypi.org/pypi/opendox/json both answer 404.
Ruled: 5962754358, item 1 (Brett Heap, 2026-10-02): "Publish to PyPI at the cut (Recommended)". The release commit and version are RULED in 5963162921, "0.1.0 (Recommended)". The bump to 0.1.0 is the last phase-3 openDox-code landing, T087 pins that commit, and the workflow publishes only the commit the openDox root's contracts/code-pin.yaml names.
The plan's split is the holder's ruling, after Copilot's review of openxFactory#1220. T101 is this PR plus the bump (DRAFT (phase 3, after T063): T099 release step: version 0.1.0 #79), and lands before T087. T099 is the publish alone, after T101, T087 and T089. The plan entry is openxFactory#1220 (bookkeeping, no Arc: trailer).
DRAFT, after T063. The holder posts READY, and the landers merge. Nothing has been published to PyPI or TestPyPI. Nothing in this PR can publish until Brett configures the two indexes and the two environments (below) and dispatches it.
openDox-code#69 (T072) and T075, 10.2 and 10.2a — an installed entry point serves all 42 bundle files; intent-feed.js is not owed (plan 034) #73 (T075) have landed, and this branch merges them (main8e377823 was merged at 7cc65f93; the head is now 7ee2b425, with mainca9e1bd5 merged, as the refresh section below reads). The build job passes on this head's own tree (proof 1). Before they landed, the verify step refused, naming exactly what those two PRs add (proof 2).
What it adds
.github/workflows/release.yml. Its only trigger is workflow_dispatch, with one input, version. It has no push, tag or pull-request trigger. Four jobs run in a chain:
build (contents: read, actions: read; it mints no identity token, and its GITHUB_TOKEN reads only). In order:
The release is the pinned commit, on main, in two steps:
(a) the ref and main: a dispatch on a tag must name v<version>, and the run's commit must be on this repository's main. The compare API must read identical or ahead.
(b) the pin: the run's commit must equal commit: in opensoft/openDox main's contracts/code-pin.yaml, whose source_repository must be this repository. Both publish jobs run this same step again, right before their upload (below).
main's head is never required, because T095 may land after T087 has pinned the bump.
A dispatch runs at the head of the ref it names. So once main has moved past the pinned commit, the dispatch names the tag v<version>, which the holder creates at that commit at the cut, on Brett's word. A dispatch on any other tag is refused, and the refusal of a moved main says to use the tag.
The root's pin is read anonymously over git: a depth-1 fetch of opensoft/openDox main, with every credential helper cleared and no prompt, in a repository of its own under $RUNNER_TEMP. So the read of another repository depends on no token. The compare reads this repository, through the API.
Both environments ask a reviewer. GitHub creates an environment that a job names if it does not exist yet, and creates it with no protection rule. So a dispatch made before Brett set up pypi would publish with nobody approving it. The step refuses unless testpypi and pypi each have a required_reviewers rule with at least one reviewer.
The release tools come from a hash lock, .github/release-tools-cpython312-linux.txt, installed with --only-binary :all: --require-hashes into a venv of their own.
The version gate. The input must be a PEP 440 version in normalized form, with no epoch (a wheel's file name escapes its !) and no local label. It must be a final release. A pre-release or a development release is refused, because the dry run installs the unversioned opendox[local], which pip resolves to a final release once one exists. It must not be 0.0.0, the scaffold's placeholder, and it must equal pyproject.toml's [project] version.
The build.python -m build --no-isolation builds the sdist, then the wheel from that sdist, with SOURCE_DATE_EPOCH set to the commit's time.
twine check --strict.
The artifact checks, before any upload:
dist/ holds exactly the sdist and the wheel named for the version;
the wheel's metadata names opendox at that version;
the wheel declares exactly the requirements pyproject.toml does, for the base install and every extra, and its local extra (T072) carries opendox[runtime] and pixeltable-pgserver. Requirements are compared as normalized requirements: the name, the extras, the version specifier or direct URL, and the marker. Only the extra == "X" clause that setuptools adds to record ownership is removed, read from the parsed marker. An extra anywhere else in a marker is refused;
the console script opendox = opendox.cli:main is declared;
every file git tracks under src/opendox/web/ is in the wheel, dotfiles included (T075's web/**/.*);
every file git tracks under src/ is in the wheel at its import path, less src/.gitkeep, and the wheel carries nothing else but its .dist-info and .data;
every tracked migrations/*.sql is in the wheel's share/opendox/migrations/ (T072).
The tracked tree is the reference, so a bundle file added later is checked with no edit here.
The built wheel runs from a fresh venv.opendox[local] is installed from the built file, with dependencies through constraints-cpython312-linux.txt and wheels only. Then, from outside the checkout, the step:
asserts the import path and the version;
walks the whole requirement closure of opendox[local] from the installed metadata, extras and markers included, and requires every requirement to be installed and satisfied;
runs the bundled server's initdb --version and postgres --version, through opendox.runtime.bundle.server_binaries();
runs opendox --help, and requires opendox generate-and-open --help to name --local.
It records the two file names and their sha256 as job outputs, and uploads dist/.
testpypi, the dry run, in the testpypi environment, with id-token: write and actions: read. It downloads dist/ and checks the two files against the build job's digests, and that there is no third file. It re-checks its approval at use time: the environment must still name a required reviewer, and this run's review history must hold an approved review of a deployment to it. A preflight follows: TestPyPI must hold no file of this version except a verified one at its verified digest (a 404 means no file yet). It checks the root's pin again, the build job's step, since the run waited on an approval. Then it runs pypa/gh-action-pypi-publish with repository-url: https://test.pypi.org/legacy/, bounded at 10 minutes. Every step declares its own timeout, and the job's (25 minutes) covers their sum (24).
testpypi-install (contents: read):
TestPyPI's JSON API must serve exactly the two verified files and digests. It is read until it does, up to 20 reads 15 s apart, so a partial or stale answer during propagation is retried, not taken as final. A read that times out is retried too.
Then T099's falsifier runs in a fresh venv: pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ "opendox[local]". The requirement is unversioned, as written. The lock, wheels-only (so no package's code runs at install) and --report are added.
The installed opendox must be the verified TestPyPI wheel, proven before anything from it runs. pip merges candidates from both indexes, and opendox is not reserved on PyPI before its first release. So this job's own python3, never the venv's interpreter, reads pip's report. The one opendox installed must come from test-files.pythonhosted.org, at the version just uploaded, with the verified wheel's sha256.
An older opendox means a lagging index, and that try is repeated.
Any other opendox refuses at once, and nothing from it is run: another host, another digest, or a newer version.
Each try is a fresh venv, up to 10 tries, 30 s apart, and each is cut off after 300 s. Then opendox --help.
Every step declares its own timeout, and the job's (90 minutes) covers their sum: checkout 5, setup-python 5, the JSON step 16 (20 reads, each up to 30 s, 15 s apart), the install 60 (10 tries, each up to 300 s, 30 s apart). A test reads each bound from the workflow.
The trade of using two indexes is stated in the step's comment. The install step receives no token, no OIDC grant and no secret. The job's GITHUB_TOKEN (contents: read) is used only by its checkout, which does not persist it. The files PyPI receives are still checked by digest.
pypi, in the pypi environment, with id-token: write and actions: read, and a 45-minute timeout that covers its steps' declared bounds (5+2+2+2+3+10+16). The same digest check, the same approval re-check, the same preflight against PyPI, the root's pin checked again (the run waited on two approvals and on TestPyPI), then pypa/gh-action-pypi-publish, bounded at 10 minutes, so the check after it always has its 16. Then PyPI's JSON API must serve exactly the two verified files, by digest. That is the same script as TestPyPI's check, reading INDEX, JSON_BASE, READS and PAUSE from its step's env.
Both uploads set skip-existing: true. A re-run after a partial upload then uploads what is missing, instead of stopping on the file the index already holds. A skipped file could differ from the verified one, so the preflight refuses before any upload if the index already holds a file at any other digest, and an index never replaces a file. The JSON check after each upload then requires exactly the build job's two digests.
No PyPI token and no stored secret appear anywhere.id-token: write is granted to testpypi and pypi only, and the workflow's default is permissions: {}. The one other credential is GitHub's own automatic GITHUB_TOKEN, passed to gh for reads of this repository's own API only. In the build job, it reads the compare with main and the environments (contents: read, actions: read). In each publish job, it reads that job's environment and this run's review history (actions: read). The openDox root's pin is read over git with no credential at all. Inputs reach every script through env:, never through an expression pasted into a script.
Every action is pinned by full commit SHA, each SHA resolved from its release tag through the git refs API:
action
tag
commit
actions/checkout
v7.0.1
3d3c42e5aac5ba805825da76410c181273ba90b1
actions/setup-python
v7.0.0
5fda3b95a4ea91299a34e894583c3862153e4b97
actions/upload-artifact
v7.0.1
043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
actions/download-artifact
v8.0.1
3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
pypa/gh-action-pypi-publish
v1.14.2
dc37677b2e1c63e2034f94d8a5b11f265b73ba33
validate.yml pins by tag. This file departs from that on the brief's word: a tag can be moved after review, and a commit cannot. It edits no other workflow. openDox-code#75 (T095) edits validate.yml only, so the two do not overlap.
.github/release-tools-cpython312-linux.txt. It hash-locks build==1.6.1, twine==7.0.0 and setuptools==84.0.0, with their dependencies (30 packages), compiled by uv pip compile --generate-hashes for cpython 3.12 on x86_64 linux. Its header gives the command.
It is separate from constraints-cpython312-linux.txt. That file is this package's own resolved dependency tree, which tests_runtime/test_deploy_shape.py holds every install of the package to. These are the tools that build the package.
tests/test_release_workflow.py was added at Copilot's request. It holds 108 hermetic cases that run the steps' own scripts, extracted from release.yml, as tests/test_triple_pin.py runs validate.yml's pin:
the shape: dispatch only, one input, no secret, id-token on the two publish jobs alone, each in its own environment, every action pinned by full SHA, and the job chain;
the release-commit steps (10 cases, run as the build job runs them, the ref step then the pin step), with a stand-in gh first on PATH for the compare. Each case builds a stand-in openDox root and redirects the root's URL to it through git's url.<base>.insteadOf, set in the environment. It also sets GIT_ALLOW_PROTOCOL=file, so a redirect that failed would refuse rather than reach GitHub. A structural case holds the anonymous read: no contents/ API call, the fetch line with -c credential.helper=, no gh call and no env in the pin step, and exactly one gh call in the ref step, which is the compare;
the pin again before each upload: in each publish job, the step right before the upload is the build job's pin step. A moved pin refuses there, and an unchanged one passes, in each job (4 cases);
the environment step (3 cases), with the stand-in gh;
the version gate (10 cases);
the artifact checks (17 cases), over a hand-built wheel and sdist in a git tree of their own. Their pyproject carries a platform marker on a base requirement, a parenthesized or marker on an extra's requirement, and a direct URL. Refused: a marker changed or dropped (an extra's, and a base requirement's), a direct URL in place of a version, a direct URL changed, and an extra under an or. Accepted: the extra's clause written first;
the preflight, in both publish jobs: its place before the pin's re-check, the two jobs sharing one script, and 6 index cases each (no file yet, both files verified, one verified file from a partial upload, the sdist at another digest, a file nobody verified, and a 503);
both publish jobs' digest check (6 cases);
skip-existing on both uploads, the two index checks as one script, and PyPI's check as the step after its upload;
the index check against a local JSON server, in both jobs: exact, lagging then exact, another sdist, a third file, and a refused read (10 cases);
each job's timeout and each step's, all read from the workflow: every step declares one, each job covers the sum of its steps', each upload is at most 10 minutes, and each retrying step covers its script's retries;
the approval at use time, in both publish jobs: the step's place, env and the jobs' permissions, one script, and seven cases each (approved; the rule removed; the environment unreadable; no approval; only the other environment approved; rejected; the history unreadable);
the dry run's install: the line's parts (the falsifier's, plus --report), and that nothing from the venv runs but pip before the verdict. Nine verdict cases run over pip reports: the verified wheel passes; PyPI at, above and at the verified digest refuses; PyPI below is retried; TestPyPI older is retried; TestPyPI newer refuses; TestPyPI at another digest refuses; no opendox refuses.
validate.yml's floors are not moved. They permit a rise, and validate.yml is T095's single-writer file (openDox-code#75), as every other phase-3 test addition has left it. The whole suite at this PR's head is in proof 9.
pyproject.toml: readme = "README.md" (the holder's decision). Without it, the metadata carries no long description and twine check --strict refuses both files. README.md's four relative links are made absolute (SECURITY.md and three docs/ links), so they resolve from the PyPI page too.
pyproject.toml's single-writer order puts this edit after T072 (openDox-code#69) and T075 (openDox-code#73). The line sits in [project], away from both PRs' hunks.
version stays 0.0.0 here. The bump to 0.1.0 (RULED 5963162921) is openDox-code#79, the last phase-3 openDox-code landing before T087.
Proofs
Every run below is local and never pushed. The harness parses release.yml and runs each job's run steps exactly as written, under bash --noprofile --norc -eo pipefail, with the uses steps emulated. It refuses to emulate pypa/gh-action-pypi-publish and stops there, so nothing is uploaded.
1. The build job on this head's own tree. The tree is 7cc65f93, this branch merged with main8e377823, which carries #69 and #73. One local-only commit sets version = "0.1.0". Round 6's verify step reads the same:
3. requirements: the local extra carries ['opendox[runtime]', 'pixeltable-pgserver<0.7,>=0.6.0']
5. web bundle: 42 of 42 tracked files are in the wheel
6. source tree: 118 of 118 tracked files under src/ are in the wheel; 0 other entries
7. migrations: 2 of 2 are in the wheel's data directory
opendox[local]: 34 distributions, every requirement installed and satisfied
initdb (PostgreSQL) 16.14
postgres (PostgreSQL) 16.14
opendox generate-and-open --help names --local
wheel-sha256=8e179862e1f6678eee9cd292f177d48718c40bc796eb1620a48ef7c0a3e4e00f
JOB build: every run step passed
The full run, before #69 and #73 landed: #73's head 0457b383 (which contains #69's head 28e195b9), merged with this PR's fix-round commit 647d246e, with the same local-only version commit. The harness runs the steps of this head's release.yml (7cc65f93) over that tree. The release-commit and environment steps run separately (4 and 5 below). Every other step passed:
version 0.1.0: pyproject.toml declares it
Checking dist/opendox-0.1.0-py3-none-any.whl: PASSED
Checking dist/opendox-0.1.0.tar.gz: PASSED
1. dist/: ['opendox-0.1.0-py3-none-any.whl', 'opendox-0.1.0.tar.gz']
2. metadata: Name opendox, Version 0.1.0
3. requirements: the local extra carries ['opendox[runtime]', 'pixeltable-pgserver<0.7,>=0.6.0']
4. console scripts: {'opendox': 'opendox.cli:main', 'opendox-runtime': 'opendox.runtime.cli:main'}
5. web bundle: 42 of 42 tracked files are in the wheel
6. source tree: 117 of 117 tracked files under src/ are in the wheel; 0 other entries
7. migrations: 2 of 2 are in the wheel's data directory
opendox 0.1.0 imports from .../runner-temp/fresh/lib/python3.12/site-packages/opendox
opendox[local]: 34 distributions, every requirement installed and satisfied
initdb (PostgreSQL) 16.14
postgres (PostgreSQL) 16.14
opendox generate-and-open --help names --local
wheel-sha256=3221e83d6b44457616b0f5db9689a7062475f5a77cd221b4eaf7dc92df1f34b7
sdist-sha256=3fdee8308c85abc85ad12ebeba0a1af5513eafa5fe3aa68d8e3c9d3b42ee9e01
JOB build: every run step passed
The wheel's digest is the same as the previous run's (3221e83d…), since SOURCE_DATE_EPOCH fixes its bytes. The sdist's contents are identical (diff -r of the two unpacked trees is empty), but its digest differs, because setuptools stamps the generated PKG-INFO and the gzip header with the build time. The workflow carries the digests of the one build it verified, so nothing depends on the sdist being reproducible.
The installed opendox --help still prints the carve-era usage: ideation-dashboard text. That is T084's to fix (openDox-code#77), not this PR's.
2. The same job at this PR's head 2b0083b9 (main 66ff7257 plus this branch, without #69 or #73), with the same local version commit. The verify step refuses, naming exactly what #69 and #73 add:
3. requirements: the local extra carries []
5. web bundle: 41 of 42 tracked files are in the wheel
6. source tree: 116 of 117 tracked files under src/ are in the wheel; 0 other entries
7. migrations: 0 of 2 are in the wheel's data directory
::error::the `local` extra carries [], not opendox[runtime] and the bundled server's package, pixeltable-pgserver
::error::the wheel lacks web files: ['opendox/web/vendor/.gitkeep']
::error::the wheel lacks tracked files: ['opendox/web/vendor/.gitkeep']
::error::the wheel lacks migrations: [...0001_identity_and_coordination.sql', ...0002_migration_state.sql']
JOB build: FAILED at step 9 (verify the artifacts)
3. The version gate refuses 0.2.0 against 0.1.0, v0.1.0, 0.1.0+local, 1!2.0, banana, 0.1.0"; print(1) #, the 0.0.0 placeholder, the pre-release 0.2.0rc1 and the development release 0.2.0.dev1. It passes 0.1.0 against 0.1.0, and the post-release 0.1.0.post1. tests/test_release_workflow.py holds each case.
4. The release-commit step against the live opensoft/openDox main, which pins 047bb4fa394f3e1bf42466062a67ef18e99f8d6a today. openDox-code main has since moved past it (now 1130e996), which is the case T095 will make at the cut. The run below is fix round 3's single step. Round 5 splits it in two with the same scripts, and the pin step's live run follows the block.
The step's script was extracted from this head's release.yml and run with:
env -i;
an empty HOME;
GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1;
GIT_TRACE_CURL on.
The only token given was the GH_TOKEN the compare uses, and git never reads it:
== main, pinned
047bb4fa394f3e1bf42466062a67ef18e99f8d6a is the commit opensoft/openDox main pins for opensoft/openDox-code
047bb4fa394f3e1bf42466062a67ef18e99f8d6a is on main (ahead), dispatched on refs/heads/main
rc=0
== tag v0.1.0, pinned
... is on main (ahead), dispatched on refs/tags/v0.1.0
rc=0
== main, an unpinned commit
::error::this run is at 9a49040500000000000000000000000000000000, and the openDox root pins 047bb4fa394f3e1bf42466062a67ef18e99f8d6a; a release publishes only the pinned commit. ...
rc=1
== tag nightly
::error::a release dispatched on a tag names v0.1.0, and this run names refs/tags/nightly
rc=1
curl-trace (main): git requests=3 authorization headers=0
curl-trace (tag): git requests=3 authorization headers=0
The three requests are GET /opensoft/openDox.git/info/refs?service=git-upload-pack and two POST /opensoft/openDox.git/git-upload-pack. None of them carries an Authorization header.
At this head, the pypi job's pin step (the same script as the build job's) was run the same way, with env -i, no token and no python but the system's python3:
== the pypi job's pin step, GITHUB_SHA=047bb4fa
047bb4fa394f3e1bf42466062a67ef18e99f8d6a is the commit opensoft/openDox main pins for opensoft/openDox-code
rc=0; git requests=3 authorization=0
== the pypi job's pin step, GITHUB_SHA=22222222
::error::this run is at 2222222222222222222222222222222222222222, and the openDox root pins 047bb4fa394f3e1bf42466062a67ef18e99f8d6a; a release publishes only the pinned ...
rc=1; git requests=3 authorization=0
A commit that is not on main, such as #73's head, reads behind on the compare call, and the step refuses it.
5. The environment step today refuses both environments with gh: Not Found (HTTP 404), then ::error::the testpypi environment cannot be read (it does not exist yet, ...), and the same for pypi. Pointed at this repository's copilot environment, which has no protection rule, it prints ::error::the copilot environment names no required reviewer, ....
6. The publish jobs' digest check passes on the build job's artifact. It refuses a wheel with one changed byte and a third file in dist/, and the harness stops at each publish action.
7. The index checks, live. The one JSON script, extracted from the pypi job, was run against both indexes:
The install step's line, pointed at a local HTTP simple index that serves the built wheel, with --extra-index-url https://pypi.org/simple/, installs opendox[local] and runs opendox --help.
Both run against TestPyPI itself only at the dry run.
8. Linters.
actionlint 1.7.12: no findings, over both workflow files.
zizmor 1.30.1, online audits, --persona=pedantic: No findings to report.
9. The whole suite at d91fd5b9 (main8e377823 merged in), in a venv with the test extra, as validate.yml installs it, the bundled PostgreSQL included:
tests/: 3013 passed, 11 skipped;
tests_runtime/: 631 passed, 166 skipped.
In the earlier suite venv, which lacked the local extra's pixeltable-pgserver, 5 tests failed and 2 errored at this head, each with the local install's PostgreSQL server is not installed. They fail the same way at main8e377823 in that venv, so the failures are environmental.
At b75205d4 (rounds 7 and 8 change release.yml and the test file only), tests/ reads 3023 passed, 11 skipped, and tests/test_release_workflow.py alone reads 93 passed. main has since gained 90ac7033 (#72, T073), which touches src/ and tests only: no pyproject.toml, no workflow and no lock. Mutants, each run against that file:
mutant
result
the pre-release refusal removed
2 failed (round 3)
the URL redirect removed
7 failed (each case refuses instead of reaching GitHub; round 3)
the credential helper left in place
1 failed (round 3)
the token-backed contents/ API read restored
8 failed (round 3)
no skip-existing on the TestPyPI upload
1 failed, 56 passed (round 4)
testpypi-install back at a 20-minute timeout
1 failed, 56 passed (round 4)
no per-try timeout 300 on pip
1 failed, 56 passed (round 4)
the index check compares names, not digests
2 failed, 55 passed (round 4)
no PyPI check after the upload
6 failed, 51 passed (round 4)
no pin re-check in the pypi job
3 failed, 59 passed (round 5)
the pypi job's re-check ignores the commit
2 failed, 60 passed (round 5)
markers dropped from the requirement comparison
4 failed, 78 passed (round 6)
a direct URL ignored
1 failed, 82 passed (round 6; the changed-URL case was added for it)
a preflight that accepts any held file
4 failed, 78 passed (round 6)
no preflight in the pypi job
7 failed, 75 passed (round 6)
the PyPI upload unbounded; at 30 minutes; the pypi job at 30; PyPI's JSON check at 10
each fails the timeout test (round 7)
the verdict ignores the host; ignores the digest; the venv's interpreter run before it; no --report
each fails (round 8)
any environment's approval counts; a rejected review counts; the rule not re-read; no approval check in pypi
each fails (round 9)
10. The approval check, live. The step's script ran with env -i and a read token against a real run of OpsxFactory, whose github-administration environment is reviewer-protected:
for github-administration, the environment that run deployed to, it passes: a required reviewer is named, and this run's deployment was approved by brettheap;
for endpoint-verification, which that run did not deploy to, it refuses: holds no approval of a deployment to endpoint-verification;
against this repository's pypi, which does not exist yet, it refuses: the pypi environment cannot be read.
11. The verdict, on real pip reports. In fresh pip 24.0 venvs, a two-index install of sampleproject resolved 4.0.0 from files.pythonhosted.org: PyPI's file, which is the risk itself. A TestPyPI-only install resolved 1.2.0 from test-files.pythonhosted.org. With each report's name rewritten to opendox, the step's verdict script refused the first (... which is not the verified wheel, rc 2) and passed the second (opendox 1.2.0 is the verified wheel, from test-files.pythonhosted.org, rc 0).
Copilot's findings at de3e3249
The repository token and the public root (r4171040914). The pin is now read anonymously over git, as above, so the read depends on no token.
You cite GitHub's documentation that the token is limited to the workflow's repository. The estate also has evidence that the token reads a public repository of the same org over git: openxFactory's openreposhape-pin-gate.yml run 37085426729 checks out public opensoft/openRepoShape with the default github.token (Contents: read, Metadata: read), and succeeds.
The REST call under that token was not measured, and the change makes the question moot.
Pre-releases and the unversioned install (r4171040935). The version gate refuses them, through Version.is_prerelease. VERSION_CASES holds 0.2.0rc1 and 0.2.0.dev1 (both refused) and 0.1.0.post1 (accepted).
Copilot's findings at 7cc65f93 and 74b87375
A partial upload is not recoverable ("previously missed" at 7cc65f93): both uploads set skip-existing, and PyPI's served digests are now checked after its upload too (round 4, 74b87375).
The retry budget exceeds the job timeout ("previously missed" at 7cc65f93): testpypi-install is at 75 minutes, its budget in full, with each pip try cut off after 300 s. A test recomputes the budget from the steps (round 4).
The pin can move before the PyPI upload (r4171232529, at 74b87375): both publish jobs run the build job's pin step again, right before their upload (round 5, 994a39d7).
Copilot's findings at 5cdb2ded
Requirement markers were dropped before the comparison (r4173500546): markers are now compared whole, removing only setuptools' extra clause, and direct URLs are compared too (round 6, d7e9ff7b, d91fd5b9).
skip-existing on PyPI could make a mixed release (r4173500563): a preflight before each upload refuses a file the build job did not verify, and a verified name at another digest, before anything is uploaded (round 6).
Copilot's findings at d91fd5b9 and 960d549b
The upload step had no bound of its own (r4173861533), and the test assumed one (r4173861563). Every step after the build now declares its timeout, and the test reads each bound from the workflow (round 7, 960d549b).
The dry run could install an opendox that PyPI serves (r4173890172). The install line stays as written and adds --report. The report must show the verified TestPyPI wheel before anything from the install runs (round 8, b75205d4).
Copilot's review at b75205d4
"Needs a closer look", with no findings and one item it had missed before: the dry run's comment said its job holds no token, but its checkout uses the job's GITHUB_TOKEN. The comment now says only the install step receives no token (f59e7105).
The refresh of 2026-10-04: mainca9e1bd5 merged, the head is 7ee2b425
This PR stays DRAFT, and it lands last among phase 3's shipped-package changes. The refresh leaves only a final main merge for later.
The whole suite at 7ee2b425, in a venv with the test extra, the bundled PostgreSQL included:
tests/: 3286 passed, 11 skipped;
tests_runtime/: 632 passed, 166 skipped.
actionlint and zizmor (pedantic) report no findings.
The dry-run path, as far as it runs without publishing. The tree is the release itself: this head merged with openDox-code#79 at its refreshed head 31361c91, so version = "0.1.0" comes from DRAFT (phase 3, after T063): T099 release step: version 0.1.0 #79 and not from a local edit. It is a local merge, never pushed.
The build job passes every run step: 42 of 42 web files, 119 of 119 source files, 2 of 2 migrations, the local extra's opendox[runtime] and pixeltable-pgserver<0.7,>=0.6.0, 34 distributions installed and satisfied, initdb and postgres 16.14, and generate-and-open --help naming --local. The installed opendox --help now prints usage: opendox, since T084 landed. The digests are wheel-sha256=4f1a6969… and sdist-sha256=d0bf9fbd…. The harness skips the release-commit and environment steps, which need the root pin and the environments, and it refuses to run the publish action.
The publish jobs' read-only steps, live:
the digest check passes on that artifact;
the preflight reads TestPyPI holds no file of opendox 0.1.0 yet and PyPI holds no file of opendox 0.1.0 yet, both reads of the public JSON APIs;
the pin step passes for 047bb4fa, which the openDox root pins today, and refuses the trial's own commit with a release publishes only the pinned commit.
The approval step was not run, because it needs the environments, which are Brett's setup.
Nothing was published or dispatched.
Copilot's finding at f59e7105
The reviewer check goes stale before the uploads (r4174341950). Each publish job re-reads its environment's rule and this run's review history, right before its preflight, pin check and upload (round 9, b3907858).
What Brett configures, once, before the first dispatch
test.pypi.org (its own account). The same pending publisher, with environment name testpypi.
GitHub, opensoft/openDox-code, Settings, Environments. Create pypi and testpypi, each with Required reviewers set to Brett. Leave "Prevent self-review" off if he both dispatches and approves. Optionally, limit deployment refs to main and tags v*. The build job refuses until both environments have a reviewer, so this must be done before the first dispatch.
A pending publisher does not reserve the project name, and opendox is free on both indexes today, so the first publish should follow the configuration closely.
At the cut, after T087 has pinned the version bump, T089 has passed and AT-R1 has passed (T095, T096), on Brett's publish word:
Dispatch release with version = 0.1.0, on main, or on the tag v0.1.0 that the holder creates at the pinned commit.
Approve testpypi.
When testpypi-install passes, approve pypi.
A job that fails after an upload can be re-run from the same run. It reuses its artifacts, and skip-existing uploads only what is missing. A second dispatch of the same version builds new files, while TestPyPI keeps the first ones under the same names, so its JSON check refuses unless the two builds are byte-identical. After the publish verifies, T099's last step is a small openDox root PR that replaces the root README's "Where opendox comes from" paragraph with the PyPI install line (openxFactory#1220).
Version
pyproject.toml's version is 0.0.0 here. Release 1's version is 0.1.0, RULED 5963162921, "0.1.0 (Recommended)": the first public release, with the API still pre-1.0. The bump is openDox-code#79.
Establish a gated, provenance-checked release pipeline that safely publishes verified openDox packages to TestPyPI and PyPI through trusted publishing.
New Features:
Add a manually triggered, reviewer-gated release workflow that builds, verifies, and publishes openDox distributions to TestPyPI and PyPI using trusted publishing.
Validate the published package through the unversioned opendox[local] installation path before the production upload.
Enhancements:
Enforce release provenance, version consistency, artifact contents, dependency metadata, file digests, index state, and post-upload availability throughout the release process.
Pin release tooling and GitHub Actions to verified versions and use reproducible, isolated build and installation environments.
Build:
Add a hash-locked release-tool dependency set for Python 3.12 Linux builds.
Include README metadata and absolute documentation links suitable for PyPI project pages.
Deployment:
Introduce staged TestPyPI and PyPI deployment jobs with OIDC trusted publishing, required environment approvals, retry handling, and safe partial-upload recovery.
Tests:
Add hermetic coverage for workflow structure, release pinning, approvals, version validation, artifact verification, digest checks, index propagation, timeout budgets, and TestPyPI installation verdicts.
…blishing (plan 034)
RULED openxFactory#656 comment 5962754358, item 1: "Publish to PyPI at the
cut (Recommended)". Release 1's ruled install line is
`pip install "opendox[local]"` (#1144 10.3, batch H's addendum,
5850003126), and today pypi.org/pypi/opendox/json answers 404. Release 1's
version is 0.1.0 (RULED 5963162921), bumped in a separate last landing.
.github/workflows/release.yml, workflow_dispatch only, with a `version`
input that must equal pyproject.toml's version (normalized PEP 440, not
the 0.0.0 placeholder):
- build: refuses unless the dispatched commit is the one opensoft/openDox
main's contracts/code-pin.yaml pins, and is on main (5963162921); refuses
unless both environments, testpypi and pypi, name a required reviewer;
installs the release tools from a hash lock; builds the sdist and the
wheel with `python -m build`; runs `twine check --strict`; verifies the
artifacts before any upload (exactly the two files, the metadata's name
and version, the `local` extra, the `opendox` console script, every
tracked web file, every tracked file under src/, the migrations);
installs the wheel with its `local` extra into a fresh venv and runs
`opendox --help`. It holds no token.
- testpypi: the dry run, in the `testpypi` environment.
- testpypi-install: TestPyPI serves the verified digests; then T099's
falsifier, `opendox[local]` from TestPyPI into a fresh venv, and
`opendox --help`.
- pypi: the publish, in the `pypi` environment.
Both uploads use pypa/gh-action-pypi-publish by trusted publishing (OIDC),
with `id-token: write` on those two jobs only, and each checks the files'
digests against the build job's first. Every action is pinned by full
commit SHA. No token and no secret appear anywhere.
.github/release-tools-cpython312-linux.txt: build 1.6.1, twine 7.0.0 and
setuptools 84.0.0 with their dependencies, hash-locked by uv.
pyproject.toml: `readme = "README.md"`, so the metadata carries a long
description and `twine check --strict` passes. pyproject.toml's
single-writer order puts this edit after T072 (openDox-code#69) and T075
(openDox-code#73). The version stays 0.0.0 here.
The artifact checks pass only once openDox-code#69 (T072, the `local`
extra and the migrations) and #73 (T075, the 42nd web file) have landed.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Adds a manually gated four-stage release workflow that builds and thoroughly verifies openDox, validates the TestPyPI installation, and publishes the exact verified artifacts to TestPyPI and PyPI via SHA-pinned actions and OIDC trusted publishing. It also adds a hash-locked release-tool environment and PyPI-compatible README metadata.
Sequence diagram for trusted openDox publishing
sequenceDiagram
actor Maintainer
participant GitHub as GitHub Actions
participant Build as build job
participant TestPyPI
participant Install as testpypi-install
participant PyPI
Maintainer->>GitHub: workflow_dispatch(version)
GitHub->>Build: verify pinned commit and environments
Build->>Build: build and verify sdist and wheel
Build->>Build: record artifact SHA256 digests
Build-->>GitHub: upload dist artifact
GitHub->>TestPyPI: request reviewer approval
Maintainer->>TestPyPI: approve deployment
TestPyPI->>TestPyPI: publish via OIDC trusted publishing
TestPyPI-->>Install: release available
Install->>TestPyPI: verify JSON digests
Install->>Install: pip install opendox[local]
Install->>Install: opendox --help
GitHub->>PyPI: request reviewer approval
Maintainer->>PyPI: approve deployment
PyPI->>PyPI: publish same artifacts via OIDC
Loading
Flow diagram for the gated openDox PyPI release
flowchart LR
Dispatch[workflow_dispatch with version] --> Build[build and verify]
Build --> TestPyPI[Publish exact artifacts to TestPyPI]
TestPyPI --> Install[Install opendox local from TestPyPI]
Install --> PyPI[Publish exact artifacts to PyPI]
TestPyPI --> ReviewTest{testpypi reviewer approval}
ReviewTest --> Install
Install --> ReviewPyPI{pypi reviewer approval}
ReviewPyPI --> PyPI
Build --> Checks[ pinned commit, version, metadata, wheel, sdist, fresh venv ]
Checks --> TestPyPI
PyPI --> OIDC[OIDC trusted publishing]
Loading
File-Level Changes
Change
Details
Files
Add a manually dispatched, OIDC-based release pipeline that builds, validates, dry-runs, and publishes identical artifacts to TestPyPI and PyPI.
Require the dispatched commit to match the openDox root pin and be present on this repository’s main branch.
Require reviewer-protected testpypi and pypi environments before building.
Validate normalized release versions, build reproducibly with hash-locked tools, and run strict metadata checks.
Verify wheel contents against tracked source, web assets, migrations, extras, and console scripts.
Install and exercise the built wheel from a fresh virtual environment.
Publish through sequential TestPyPI and PyPI jobs using trusted publishing and digest verification.
Add retrying TestPyPI JSON and installation checks before the production publish.
.github/workflows/release.yml
Provide a reproducible, hash-locked toolchain for release artifact construction.
Lock build, twine, setuptools, and transitive dependencies for CPython 3.12 on Linux.
Install release tooling with binary-only, hash-verified pip installation in an isolated virtual environment.
.github/release-tools-cpython312-linux.txt
Make the package metadata suitable for a strict PyPI release.
Declare README.md as the project readme so built distributions include a Markdown long description.
pyproject.toml
Tips and commands
Interacting with Sourcery
Trigger a new review: Comment @sourcery-ai review on the pull request.
Continue discussions: Reply directly to Sourcery's review comments.
Generate a GitHub issue from a review comment: Ask Sourcery to create an
issue from a review comment by replying to it. You can also reply to a
review comment with @sourcery-ai issue to create an issue from it.
Generate a pull request title: Write @sourcery-ai anywhere in the pull
request title to generate a title at any time. You can also comment @sourcery-ai title on the pull request to (re-)generate the title at any time.
Generate a pull request summary: Write @sourcery-ai summary anywhere in
the pull request body to generate a PR summary at any time exactly where you
want it. You can also comment @sourcery-ai summary on the pull request to
(re-)generate the summary at any time.
Generate reviewer's guide: Comment @sourcery-ai guide on the pull
request to (re-)generate the reviewer's guide at any time.
Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
pull request to resolve all Sourcery comments. Useful if you've already
addressed all the comments and don't want to see them anymore.
Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
request to dismiss all existing Sourcery reviews. Especially useful if you
want to start fresh with a new review - don't forget to comment @sourcery-ai review to trigger a new review!
…re, the release tag, the install line as written, and tests (Copilot review)
Copilot's review of openDox-code#78 at 4173dbc, and of openxFactory#1220
at 00a5bdae, with the holder's ruling on the split (T101):
- The artifact checks compare the wheel's requirements with
pyproject.toml's, for the base install and every extra, as normalized
requirements, and require the `local` extra to carry opendox[runtime] and
pixeltable-pgserver (r4170775660).
- The fresh venv walks the whole requirement closure of opendox[local]
from the installed metadata, extras and markers included, and runs the
bundled server's `initdb --version` and `postgres --version` through
opendox.runtime.bundle.server_binaries() (r4170775660).
- The version gate refuses an epoch, whose `!` a wheel's file name escapes
(r4170775696).
- The release-commit step never requires main's head: the commit must
equal the openDox root's pin and be on main, and a dispatch on a tag must
name v<version> (the holder's ruling).
- testpypi-install runs the falsifier's install line as written,
unversioned, then requires the installed version to be the one uploaded,
each try in a fresh venv (openxFactory#1220, r4170767305).
- tests/test_release_workflow.py: 39 hermetic cases that run the steps'
own scripts, with a stand-in gh, over a hand-built wheel and sdist
(r4170775717). validate.yml's floors are not moved here: they permit a
rise, and validate.yml is T095's single-writer file (openDox-code#75).
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… wording is exact, and README's links resolve on PyPI (Copilot review)
Copilot's review of openDox-code#78 at 2b0083b:
- r4170898255: the JSON read kept a partial or stale answer as final. It
now retries, up to 20 reads 15 s apart, until TestPyPI serves exactly
the build job's two files and digests, and only then refuses.
- r4170898293: the header said "no token". It now says no PyPI token and
no stored secret, and names the one other credential, GitHub's own
GITHUB_TOKEN, which the build job passes to gh for its two API reads,
with contents: read and actions: read.
- r4170898319: README.md's four relative links (SECURITY.md and two docs)
are absolute, so they resolve from the PyPI page that `readme` makes of
README.md.
And openxFactory#1220's r4170908474: a dispatch runs at the head of the
ref it names, so the header and the release-commit refusal say to
dispatch on the tag v<version> at the pinned commit once main has moved.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-releases (Copilot review)
Copilot's review at de3e324 (r4171040914, r4171040935):
- The openDox root's contracts/code-pin.yaml is read by an anonymous
depth-1 git fetch of opensoft/openDox main (public), with every
credential helper cleared and no prompt, in a repository of its own
under RUNNER_TEMP. The read of another repository no longer depends on
this job's repository-scoped GITHUB_TOKEN. The step's one gh call is the
compare with this repository's main.
- The version gate refuses a pre-release or a development release: the
dry run installs the unversioned "opendox[local]", which pip resolves to
a final release once one exists, so it could never install what was
uploaded.
- tests/test_release_workflow.py: the release-commit cases read a
stand-in root through git's url.insteadOf in the environment, with
GIT_ALLOW_PROTOCOL=file so no case can reach the network; a root with no
pin and an unreachable root refuse; a structural case holds the
anonymous read; the version gate refuses 0.2.0rc1 and 0.2.0.dev1 and
passes 0.1.0.post1. 44 cases.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Enable skip-existing for recoverable TestPyPI uploads
.github/workflows/release.yml:534
A partial TestPyPI upload is not recoverable as written. This pinned action defaults skip-existing to false and invokes Twine over both files, so if the wheel uploads before the sdist fails, a rerun stops on the immutable duplicate wheel instead of uploading the missing sdist. Enable skip-existing; the following JSON gate already verifies that the remote files and digests are exactly the build outputs.
This issue also appears on line 667 of the same file.
Align job timeout with advertised retry budgets
.github/workflows/release.yml:540
The advertised retry budgets cannot fit this job timeout. If the first 19 JSON reads consume their 30-second timeout and the 20th succeeds, that gate takes 885 seconds; the nine 30-second waits before a successful tenth install attempt then bring the minimum to 19m15s before checkout/setup, venv creation, or any pip runtime. The job can therefore be cancelled before completing the promised retries.
…cked after the upload, and job timeouts that fit the retries (Copilot review)
Copilot's review at 7cc65f9 ("Needs a closer look", two items it had
missed before):
- Both pypa/gh-action-pypi-publish steps set skip-existing: a re-run after
a partial upload uploads the rest instead of stopping on the file the
index already holds. An index keeps each file name once, so a skipped
file could differ from the verified one; each upload is therefore
followed by the check that the index serves exactly the build job's two
digests. TestPyPI's check already followed its upload, and the pypi job
now runs the same script against PyPI after its upload. The one script
reads INDEX, JSON_BASE, READS and PAUSE from its step's env, and catches
a read timeout too.
- testpypi-install's timeout is 75 minutes, its budget in full: the JSON
step (20 reads, each up to 30 s, 15 s apart: 15 min), the install (10
tries, each cut off after 300 s, 30 s apart: 55 min), and setup (5 min).
The pypi job's is 30 minutes (the upload, the JSON step, the download).
- tests/test_release_workflow.py (57 cases): skip-existing on both
uploads; the two index checks are one script, and PyPI's follows the
upload; the index check against a local server (exact, lagging, another
sdist, a third file, a refused read); each job's timeout recomputed
from its steps.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… (Copilot review)
Copilot's review at 74b8737 (r4171232529): the pin was checked only in
the build job, and the run then waits on approvals (and, before PyPI, on
TestPyPI), so a pin the openDox root moved meanwhile would not stop the
irreversible upload.
- The build job's release-commit step splits in two: the ref and main
check (a tag must be v<version>; the compare with this repository's
main, through the API), and the pin check (the anonymous git read of
opensoft/openDox main's contracts/code-pin.yaml, run with python3).
- testpypi and pypi each run that pin step again, the same script, as the
step right before their upload; it needs no token.
- tests/test_release_workflow.py (62 cases): the release-commit cases run
both build steps; the pin step carries no gh call and no env, and the
ref step's one gh call is the compare; each publish job's step before
its upload is the build job's pin step; a moved pin refuses there and an
unchanged one passes, in each job.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ad (Copilot review)
Copilot's review at 5cdb2de (r4173500546, r4173500563):
- The requirement comparison keeps each requirement's marker, less only
setuptools' extra-ownership clause (`and extra == "X"`, read from the
parsed marker), and a direct URL; an extra under a top-level `or`, or
nested, is refused.
- Before each upload, the index must hold no file of this version but a
verified one at its verified digest (a 404 is no file yet), so
skip-existing can never make a mixed release. The same script in both
publish jobs, before the pin's re-check.
- tests/test_release_workflow.py (82 cases): marker cases (an extra's or a
base requirement's marker changed or dropped, a direct URL, the extra's
clause first, an extra under an or); the preflight's place and the
index cases.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…arison needs)
The artifact tests' pyproject gains a direct-URL requirement, and a case
whose wheel names another URL for it; with the URL dropped from the
comparison, that case passes, so it holds the URL half of r4173500546.
83 cases.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… test reads each bound (Copilot review)
Copilot's review at d91fd5b (r4173861533, r4173861563): the PyPI upload
had no bound of its own, so it could take the time the JSON check after
it is owed, and the test assumed a 10-minute upload instead of reading it.
- Every step of testpypi, testpypi-install and pypi declares
timeout-minutes (the uploads 10, each JSON check 16, the install 60, the
pin step 3, here and in the build job, where it stays the same step), and
each job's timeout covers the sum: testpypi 25, testpypi-install 90,
pypi 40. A cut-off upload stays recoverable through the preflight and
skip-existing.
- test_each_job_timeout_covers_the_retries_it_promises reads every bound
from the workflow: each step declares one, each job covers the sum, each
upload is at most 10 minutes, and each JSON check and the install cover
their scripts' retry budgets. Mutants: the PyPI upload unbounded, at 30
minutes, the pypi job at 30, PyPI's JSON step at 10: each fails.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eel before running it (Copilot review)
Copilot's review at 960d549 (r4173890172): with PyPI as the extra index,
pip merges candidates from both, and opendox is not reserved on PyPI
before its first release, so the unversioned install line could resolve
an opendox PyPI serves; the step checked only the version string, and
ran the venv's interpreter to read it.
- The install line stays T099's falsifier as written (unversioned,
wheels only) and gains --report. This job's own python3, never the
venv's, reads the report before anything from the install runs: the
one opendox installed must come from test-files.pythonhosted.org, at
VERSION, with the verified wheel's sha256. An older opendox (an index
that lags) is tried again; any other opendox (another host, another
digest, VERSION or above) refuses at once. opendox --help runs only
after that.
- tests/test_release_workflow.py (93 cases): the install line's parts
and that nothing from the venv runs but pip before the verdict; nine
verdict cases over pip reports (the verified wheel; PyPI at, above and
below the version, and at the verified digest; TestPyPI older, newer,
at another digest; no opendox). Mutants: the host ignored, the digest
ignored, the venv's interpreter run before the verdict, no --report:
each fails.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Correct inaccurate claim about credentials used by the job
.github/workflows/release.yml:799
This credential claim is inaccurate: the job explicitly grants contents: read, and the preceding actions/checkout uses that job's automatic GITHUB_TOKEN (even though persist-credentials: false removes it afterward). Clarify that only the install/run step receives no token; otherwise the documented threat model understates the credentials used by this job.
…iew)
Copilot's review at b75205d, an item it had missed before: the comment
said the testpypi-install job holds no token, but its checkout uses the
job's GITHUB_TOKEN (contents: read), without persisting it. The comment
now says the install step receives no token, no OIDC grant and no secret,
and that the token is used only by the checkout.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ilot review)
Copilot's review at f59e710 (r4174341950): the build job's check that
both environments ask a reviewer goes stale while the run waits; a rule
removed meanwhile would let a publish job start with nobody approving it.
- Each publish job, right before its preflight, pin check and upload,
re-reads its environment: it must still name a required reviewer, and
this run's review history (actions/runs/<id>/approvals) must hold an
approved review of a deployment to it. Either missing refuses, and
nothing is uploaded. The publish jobs gain actions: read for those two
reads of this repository's API; the header says so. Budgets: testpypi
24 of 25 minutes, pypi 40 of 45.
- tests/test_release_workflow.py (108 cases): the step's place, env and
permissions in both jobs, one script; seven cases in each job (approved;
the rule removed; the environment unreadable; no approval; only the other
environment approved; rejected; the history unreadable). Mutants: any
environment's approval counts, a rejected review counts, the rule not
re-read, no check in pypi: each fails.
Arc: neutral-product-standalone-operability
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🔵 Needs a closer look
The irreversible publishing workflow and external environment configuration warrant final human validation despite extensive safeguards.
Review effort: Balanced Findings: None
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Plan 034's T101, "T099's release step" (with openDox-code#79): a release workflow that publishes openDox to PyPI by trusted publishing (OIDC). It makes release 1's ruled install line,
pip install "opendox[local]", work as written (#1144 10.3, as T007's batch H addendum reads, R1Q15 (b) and R1Q16 (iii), RULED5850003126). Todayhttps://pypi.org/pypi/opendox/jsonandhttps://test.pypi.org/pypi/opendox/jsonboth answer 404.5962754358, item 1 (Brett Heap, 2026-10-02): "Publish to PyPI at the cut (Recommended)". The release commit and version are RULED in5963162921, "0.1.0 (Recommended)". The bump to 0.1.0 is the last phase-3 openDox-code landing, T087 pins that commit, and the workflow publishes only the commit the openDox root'scontracts/code-pin.yamlnames.Arc:trailer).main8e377823was merged at7cc65f93; the head is now7ee2b425, withmainca9e1bd5merged, as the refresh section below reads). The build job passes on this head's own tree (proof 1). Before they landed, the verify step refused, naming exactly what those two PRs add (proof 2).What it adds
.github/workflows/release.yml. Its only trigger isworkflow_dispatch, with one input,version. It has no push, tag or pull-request trigger. Four jobs run in a chain:build(contents: read,actions: read; it mints no identity token, and itsGITHUB_TOKENreads only). In order:The release is the pinned commit, on
main, in two steps:main: a dispatch on a tag must namev<version>, and the run's commit must be on this repository'smain. The compare API must readidenticalorahead.commit:in opensoft/openDoxmain'scontracts/code-pin.yaml, whosesource_repositorymust be this repository. Both publish jobs run this same step again, right before their upload (below).main's head is never required, because T095 may land after T087 has pinned the bump.mainhas moved past the pinned commit, the dispatch names the tagv<version>, which the holder creates at that commit at the cut, on Brett's word. A dispatch on any other tag is refused, and the refusal of a movedmainsays to use the tag.main, with every credential helper cleared and no prompt, in a repository of its own under$RUNNER_TEMP. So the read of another repository depends on no token. The compare reads this repository, through the API.Both environments ask a reviewer. GitHub creates an environment that a job names if it does not exist yet, and creates it with no protection rule. So a dispatch made before Brett set up
pypiwould publish with nobody approving it. The step refuses unlesstestpypiandpypieach have arequired_reviewersrule with at least one reviewer.The release tools come from a hash lock,
.github/release-tools-cpython312-linux.txt, installed with--only-binary :all: --require-hashesinto a venv of their own.The version gate. The input must be a PEP 440 version in normalized form, with no epoch (a wheel's file name escapes its
!) and no local label. It must be a final release. A pre-release or a development release is refused, because the dry run installs the unversionedopendox[local], which pip resolves to a final release once one exists. It must not be0.0.0, the scaffold's placeholder, and it must equalpyproject.toml's[project] version.The build.
python -m build --no-isolationbuilds the sdist, then the wheel from that sdist, withSOURCE_DATE_EPOCHset to the commit's time.twine check --strict.The artifact checks, before any upload:
dist/holds exactly the sdist and the wheel named for the version;opendoxat that version;pyproject.tomldoes, for the base install and every extra, and itslocalextra (T072) carriesopendox[runtime]andpixeltable-pgserver. Requirements are compared as normalized requirements: the name, the extras, the version specifier or direct URL, and the marker. Only theextra == "X"clause that setuptools adds to record ownership is removed, read from the parsed marker. Anextraanywhere else in a marker is refused;opendox = opendox.cli:mainis declared;src/opendox/web/is in the wheel, dotfiles included (T075'sweb/**/.*);src/is in the wheel at its import path, lesssrc/.gitkeep, and the wheel carries nothing else but its.dist-infoand.data;migrations/*.sqlis in the wheel'sshare/opendox/migrations/(T072).The tracked tree is the reference, so a bundle file added later is checked with no edit here.
The built wheel runs from a fresh venv.
opendox[local]is installed from the built file, with dependencies throughconstraints-cpython312-linux.txtand wheels only. Then, from outside the checkout, the step:opendox[local]from the installed metadata, extras and markers included, and requires every requirement to be installed and satisfied;initdb --versionandpostgres --version, throughopendox.runtime.bundle.server_binaries();opendox --help, and requiresopendox generate-and-open --helpto name--local.It records the two file names and their sha256 as job outputs, and uploads
dist/.testpypi, the dry run, in thetestpypienvironment, withid-token: writeandactions: read. It downloadsdist/and checks the two files against the build job's digests, and that there is no third file. It re-checks its approval at use time: the environment must still name a required reviewer, and this run's review history must hold an approved review of a deployment to it. A preflight follows: TestPyPI must hold no file of this version except a verified one at its verified digest (a 404 means no file yet). It checks the root's pin again, the build job's step, since the run waited on an approval. Then it runspypa/gh-action-pypi-publishwithrepository-url: https://test.pypi.org/legacy/, bounded at 10 minutes. Every step declares its own timeout, and the job's (25 minutes) covers their sum (24).testpypi-install(contents: read):pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ "opendox[local]". The requirement is unversioned, as written. The lock, wheels-only (so no package's code runs at install) and--reportare added.opendoxmust be the verified TestPyPI wheel, proven before anything from it runs. pip merges candidates from both indexes, andopendoxis not reserved on PyPI before its first release. So this job's ownpython3, never the venv's interpreter, reads pip's report. The oneopendoxinstalled must come fromtest-files.pythonhosted.org, at the version just uploaded, with the verified wheel's sha256.opendoxmeans a lagging index, and that try is repeated.opendoxrefuses at once, and nothing from it is run: another host, another digest, or a newer version.opendox --help.GITHUB_TOKEN(contents: read) is used only by its checkout, which does not persist it. The files PyPI receives are still checked by digest.pypi, in thepypienvironment, withid-token: writeandactions: read, and a 45-minute timeout that covers its steps' declared bounds (5+2+2+2+3+10+16). The same digest check, the same approval re-check, the same preflight against PyPI, the root's pin checked again (the run waited on two approvals and on TestPyPI), thenpypa/gh-action-pypi-publish, bounded at 10 minutes, so the check after it always has its 16. Then PyPI's JSON API must serve exactly the two verified files, by digest. That is the same script as TestPyPI's check, readingINDEX,JSON_BASE,READSandPAUSEfrom its step'senv.Both uploads set
skip-existing: true. A re-run after a partial upload then uploads what is missing, instead of stopping on the file the index already holds. A skipped file could differ from the verified one, so the preflight refuses before any upload if the index already holds a file at any other digest, and an index never replaces a file. The JSON check after each upload then requires exactly the build job's two digests.No PyPI token and no stored secret appear anywhere.
id-token: writeis granted totestpypiandpypionly, and the workflow's default ispermissions: {}. The one other credential is GitHub's own automaticGITHUB_TOKEN, passed toghfor reads of this repository's own API only. In the build job, it reads the compare withmainand the environments (contents: read,actions: read). In each publish job, it reads that job's environment and this run's review history (actions: read). The openDox root's pin is read over git with no credential at all. Inputs reach every script throughenv:, never through an expression pasted into a script.Every action is pinned by full commit SHA, each SHA resolved from its release tag through the git refs API:
actions/checkout3d3c42e5aac5ba805825da76410c181273ba90b1actions/setup-python5fda3b95a4ea91299a34e894583c3862153e4b97actions/upload-artifact043fb46d1a93c77aae656e7c1c64a875d1fc6a0aactions/download-artifact3e5f45b2cfb9172054b4087a40e8e0b5a5461e7cpypa/gh-action-pypi-publishdc37677b2e1c63e2034f94d8a5b11f265b73ba33validate.ymlpins by tag. This file departs from that on the brief's word: a tag can be moved after review, and a commit cannot. It edits no other workflow. openDox-code#75 (T095) editsvalidate.ymlonly, so the two do not overlap..github/release-tools-cpython312-linux.txt. It hash-locksbuild==1.6.1,twine==7.0.0andsetuptools==84.0.0, with their dependencies (30 packages), compiled byuv pip compile --generate-hashesfor cpython 3.12 on x86_64 linux. Its header gives the command.constraints-cpython312-linux.txt. That file is this package's own resolved dependency tree, whichtests_runtime/test_deploy_shape.pyholds every install of the package to. These are the tools that build the package.setuptools==84.0.0is the version T072, 13.1: the bundled PostgreSQL server, the local install's own child (plan 034) #69 pins there for thetestextra.tests/test_release_workflow.pywas added at Copilot's request. It holds 108 hermetic cases that run the steps' own scripts, extracted fromrelease.yml, astests/test_triple_pin.pyrunsvalidate.yml's pin:id-tokenon the two publish jobs alone, each in its own environment, every action pinned by full SHA, and the job chain;ghfirst onPATHfor the compare. Each case builds a stand-in openDox root and redirects the root's URL to it through git'surl.<base>.insteadOf, set in the environment. It also setsGIT_ALLOW_PROTOCOL=file, so a redirect that failed would refuse rather than reach GitHub. A structural case holds the anonymous read: nocontents/API call, the fetch line with-c credential.helper=, noghcall and noenvin the pin step, and exactly oneghcall in the ref step, which is the compare;gh;ormarker on an extra's requirement, and a direct URL. Refused: a marker changed or dropped (an extra's, and a base requirement's), a direct URL in place of a version, a direct URL changed, and an extra under anor. Accepted: the extra's clause written first;skip-existingon both uploads, the two index checks as one script, and PyPI's check as the step after its upload;envand the jobs' permissions, one script, and seven cases each (approved; the rule removed; the environment unreadable; no approval; only the other environment approved; rejected; the history unreadable);--report), and that nothing from the venv runs but pip before the verdict. Nine verdict cases run over pip reports: the verified wheel passes; PyPI at, above and at the verified digest refuses; PyPI below is retried; TestPyPI older is retried; TestPyPI newer refuses; TestPyPI at another digest refuses; noopendoxrefuses.validate.yml's floors are not moved. They permit a rise, andvalidate.ymlis T095's single-writer file (openDox-code#75), as every other phase-3 test addition has left it. The whole suite at this PR's head is in proof 9.pyproject.toml:readme = "README.md"(the holder's decision). Without it, the metadata carries no long description andtwine check --strictrefuses both files. README.md's four relative links are made absolute (SECURITY.mdand threedocs/links), so they resolve from the PyPI page too.[project], away from both PRs' hunks.versionstays0.0.0here. The bump to0.1.0(RULED5963162921) is openDox-code#79, the last phase-3 openDox-code landing before T087.Proofs
Every run below is local and never pushed. The harness parses
release.ymland runs each job'srunsteps exactly as written, underbash --noprofile --norc -eo pipefail, with theusessteps emulated. It refuses to emulatepypa/gh-action-pypi-publishand stops there, so nothing is uploaded.1. The build job on this head's own tree. The tree is
7cc65f93, this branch merged withmain8e377823, which carries #69 and #73. One local-only commit setsversion = "0.1.0". Round 6's verify step reads the same:The full run, before #69 and #73 landed: #73's head
0457b383(which contains #69's head28e195b9), merged with this PR's fix-round commit647d246e, with the same local-only version commit. The harness runs the steps of this head'srelease.yml(7cc65f93) over that tree. The release-commit and environment steps run separately (4 and 5 below). Every other step passed:The wheel's digest is the same as the previous run's (
3221e83d…), sinceSOURCE_DATE_EPOCHfixes its bytes. The sdist's contents are identical (diff -rof the two unpacked trees is empty), but its digest differs, because setuptools stamps the generatedPKG-INFOand the gzip header with the build time. The workflow carries the digests of the one build it verified, so nothing depends on the sdist being reproducible.The installed
opendox --helpstill prints the carve-erausage: ideation-dashboardtext. That is T084's to fix (openDox-code#77), not this PR's.2. The same job at this PR's head
2b0083b9(main66ff7257plus this branch, without #69 or #73), with the same local version commit. The verify step refuses, naming exactly what #69 and #73 add:3. The version gate refuses
0.2.0against0.1.0,v0.1.0,0.1.0+local,1!2.0,banana,0.1.0"; print(1) #, the0.0.0placeholder, the pre-release0.2.0rc1and the development release0.2.0.dev1. It passes0.1.0against0.1.0, and the post-release0.1.0.post1.tests/test_release_workflow.pyholds each case.4. The release-commit step against the live opensoft/openDox
main, which pins047bb4fa394f3e1bf42466062a67ef18e99f8d6atoday. openDox-codemainhas since moved past it (now1130e996), which is the case T095 will make at the cut. The run below is fix round 3's single step. Round 5 splits it in two with the same scripts, and the pin step's live run follows the block.The step's script was extracted from this head's
release.ymland run with:env -i;HOME;GIT_CONFIG_GLOBAL=/dev/nullandGIT_CONFIG_NOSYSTEM=1;GIT_TRACE_CURLon.The only token given was the
GH_TOKENthe compare uses, and git never reads it:The three requests are
GET /opensoft/openDox.git/info/refs?service=git-upload-packand twoPOST /opensoft/openDox.git/git-upload-pack. None of them carries anAuthorizationheader.At this head, the
pypijob's pin step (the same script as the build job's) was run the same way, withenv -i, no token and nopythonbut the system'spython3:A commit that is not on
main, such as #73's head, readsbehindon the compare call, and the step refuses it.5. The environment step today refuses both environments with
gh: Not Found (HTTP 404), then::error::the testpypi environment cannot be read (it does not exist yet, ...), and the same forpypi. Pointed at this repository'scopilotenvironment, which has no protection rule, it prints::error::the copilot environment names no required reviewer, ....6. The publish jobs' digest check passes on the build job's artifact. It refuses a wheel with one changed byte and a third file in
dist/, and the harness stops at each publish action.7. The index checks, live. The one JSON script, extracted from the
pypijob, was run against both indexes:testpypi-install.--extra-index-url https://pypi.org/simple/, installsopendox[local]and runsopendox --help.8. Linters.
actionlint1.7.12: no findings, over both workflow files.zizmor1.30.1, online audits,--persona=pedantic:No findings to report.9. The whole suite at
d91fd5b9(main8e377823merged in), in a venv with thetestextra, asvalidate.ymlinstalls it, the bundled PostgreSQL included:tests/:3013 passed, 11 skipped;tests_runtime/:631 passed, 166 skipped.In the earlier suite venv, which lacked the
localextra'spixeltable-pgserver, 5 tests failed and 2 errored at this head, each withthe local install's PostgreSQL server is not installed. They fail the same way atmain8e377823in that venv, so the failures are environmental.At
b75205d4(rounds 7 and 8 changerelease.ymland the test file only),tests/reads3023 passed, 11 skipped, andtests/test_release_workflow.pyalone reads93 passed.mainhas since gained90ac7033(#72, T073), which touchessrc/and tests only: nopyproject.toml, no workflow and no lock. Mutants, each run against that file:2 failed(round 3)7 failed(each case refuses instead of reaching GitHub; round 3)1 failed(round 3)contents/API read restored8 failed(round 3)skip-existingon the TestPyPI upload1 failed, 56 passed(round 4)testpypi-installback at a 20-minute timeout1 failed, 56 passed(round 4)timeout 300on pip1 failed, 56 passed(round 4)2 failed, 55 passed(round 4)6 failed, 51 passed(round 4)pypijob3 failed, 59 passed(round 5)pypijob's re-check ignores the commit2 failed, 60 passed(round 5)4 failed, 78 passed(round 6)1 failed, 82 passed(round 6; the changed-URL case was added for it)4 failed, 78 passed(round 6)pypijob7 failed, 75 passed(round 6)pypijob at 30; PyPI's JSON check at 10--reportpypi10. The approval check, live. The step's script ran with
env -iand a read token against a real run of OpsxFactory, whosegithub-administrationenvironment is reviewer-protected:github-administration, the environment that run deployed to, it passes:a required reviewer is named, and this run's deployment was approved by brettheap;endpoint-verification, which that run did not deploy to, it refuses:holds no approval of a deployment to endpoint-verification;pypi, which does not exist yet, it refuses:the pypi environment cannot be read.11. The verdict, on real pip reports. In fresh pip 24.0 venvs, a two-index install of
sampleprojectresolved4.0.0 from files.pythonhosted.org: PyPI's file, which is the risk itself. A TestPyPI-only install resolved1.2.0 from test-files.pythonhosted.org. With each report's name rewritten toopendox, the step's verdict script refused the first (... which is not the verified wheel, rc 2) and passed the second (opendox 1.2.0 is the verified wheel, from test-files.pythonhosted.org, rc 0).Copilot's findings at
de3e3249openreposhape-pin-gate.ymlrun37085426729checks out publicopensoft/openRepoShapewith the defaultgithub.token(Contents: read,Metadata: read), and succeeds.Version.is_prerelease.VERSION_CASESholds0.2.0rc1and0.2.0.dev1(both refused) and0.1.0.post1(accepted).Copilot's findings at
7cc65f93and74b873757cc65f93): both uploads setskip-existing, and PyPI's served digests are now checked after its upload too (round 4,74b87375).7cc65f93):testpypi-installis at 75 minutes, its budget in full, with each pip try cut off after 300 s. A test recomputes the budget from the steps (round 4).74b87375): both publish jobs run the build job's pin step again, right before their upload (round 5,994a39d7).Copilot's findings at
5cdb2dedd7e9ff7b,d91fd5b9).skip-existingon PyPI could make a mixed release (r4173500563): a preflight before each upload refuses a file the build job did not verify, and a verified name at another digest, before anything is uploaded (round 6).Copilot's findings at
d91fd5b9and960d549b960d549b).opendoxthat PyPI serves (r4173890172). The install line stays as written and adds--report. The report must show the verified TestPyPI wheel before anything from the install runs (round 8,b75205d4).Copilot's review at
b75205d4"Needs a closer look", with no findings and one item it had missed before: the dry run's comment said its job holds no token, but its checkout uses the job's
GITHUB_TOKEN. The comment now says only the install step receives no token (f59e7105).The refresh of 2026-10-04:
mainca9e1bd5merged, the head is7ee2b425This PR stays DRAFT, and it lands last among phase 3's shipped-package changes. The refresh leaves only a final
mainmerge for later.The merges.
mainc4b55cc4was merged atd5dc825a: T073, 13.4a — /capabilities gains an install block, read from the serving process's own settings (plan 034) #72 (T073), T084, 4.3: the last deferred reaches through declared seams; consumer_reach retired (plan 034) #77 (T084), T103, every loopback route checks the Host (DNS rebinding) (plan 034) #80 (T103), T102, the staging workbench offers the editors and the chat rail by scope (plan 034) #81 (T102) and T102 follow-on: the chat rail reads a thread only with a branch session (plan 034) #85 (the T102 follow-on).mainca9e1bd5was merged at7ee2b425: T082, 16.5: every other surface works with no model (plan 034) #76 (T082). Both merges were clean. None of them touchespyproject.toml, the lock orrelease.yml. T082, 16.5: every other surface works with no model (plan 034) #76 re-pinsvalidate.yml's floors, and this PR's added cases only raise the counts above them; it adds no skip.The whole suite at
7ee2b425, in a venv with thetestextra, the bundled PostgreSQL included:tests/:3286 passed, 11 skipped;tests_runtime/:632 passed, 166 skipped.actionlint and zizmor (pedantic) report no findings.
The dry-run path, as far as it runs without publishing. The tree is the release itself: this head merged with openDox-code#79 at its refreshed head
31361c91, soversion = "0.1.0"comes from DRAFT (phase 3, after T063): T099 release step: version 0.1.0 #79 and not from a local edit. It is a local merge, never pushed.The
buildjob passes every run step: 42 of 42 web files, 119 of 119 source files, 2 of 2 migrations, thelocalextra'sopendox[runtime]andpixeltable-pgserver<0.7,>=0.6.0, 34 distributions installed and satisfied,initdbandpostgres16.14, andgenerate-and-open --helpnaming--local. The installedopendox --helpnow printsusage: opendox, since T084 landed. The digests arewheel-sha256=4f1a6969…andsdist-sha256=d0bf9fbd…. The harness skips the release-commit and environment steps, which need the root pin and the environments, and it refuses to run the publish action.The publish jobs' read-only steps, live:
TestPyPI holds no file of opendox 0.1.0 yetandPyPI holds no file of opendox 0.1.0 yet, both reads of the public JSON APIs;047bb4fa, which the openDox root pins today, and refuses the trial's own commit witha release publishes only the pinned commit.The approval step was not run, because it needs the environments, which are Brett's setup.
Nothing was published or dispatched.
Copilot's finding at
f59e7105b3907858).What Brett configures, once, before the first dispatch
opendox. Owner:opensoft. Repository name:openDox-code. Workflow name:release.yml. Environment name:pypi.testpypi.pypiandtestpypi, each with Required reviewers set to Brett. Leave "Prevent self-review" off if he both dispatches and approves. Optionally, limit deployment refs tomainand tagsv*. The build job refuses until both environments have a reviewer, so this must be done before the first dispatch.A pending publisher does not reserve the project name, and
opendoxis free on both indexes today, so the first publish should follow the configuration closely.At the cut, after T087 has pinned the version bump, T089 has passed and AT-R1 has passed (T095, T096), on Brett's publish word:
releasewithversion=0.1.0, onmain, or on the tagv0.1.0that the holder creates at the pinned commit.testpypi.testpypi-installpasses, approvepypi.A job that fails after an upload can be re-run from the same run. It reuses its artifacts, and
skip-existinguploads only what is missing. A second dispatch of the same version builds new files, while TestPyPI keeps the first ones under the same names, so its JSON check refuses unless the two builds are byte-identical. After the publish verifies, T099's last step is a small openDox root PR that replaces the root README's "Whereopendoxcomes from" paragraph with the PyPI install line (openxFactory#1220).Version
pyproject.toml'sversionis0.0.0here. Release 1's version is 0.1.0, RULED5963162921, "0.1.0 (Recommended)": the first public release, with the API still pre-1.0. The bump is openDox-code#79.🤖 Generated with Claude Code
Summary by Sourcery
Establish a gated, provenance-checked release pipeline that safely publishes verified openDox packages to TestPyPI and PyPI through trusted publishing.
New Features:
opendox[local]installation path before the production upload.Enhancements:
Build:
Deployment:
Tests: