ci: add npm + PyPI publish workflows for eval#11
Conversation
Adapted from extract's publish-npm.yml/publish-pypi.yml templates: - publish-pypi.yml: release-triggered, PyPI Trusted Publisher (environment: pypi), no prompts-copy step (eval has no bundled-resource packaging need extract has). Working directory is repo root, matching eval's flat pyproject.toml layout (extract uses packages/python/). - publish-npm.yml: release-triggered + workflow_dispatch, npm Trusted Publishing (--provenance, no token). working-directory: ts, matching eval's ts/ subdirectory layout. Uses `npm run typecheck` in place of extract's `npm run build` -- eval's tsconfig.json has noEmit: true and package.json has no build script; the package ships raw TS source (exports -> ./src/mod.ts) by design, so typecheck is the correct pre-publish safety gate, not a missing step. Both verified locally end-to-end short of the actual registry upload: python -m build + twine check both PASSED; npm ci + npm run typecheck clean; npm publish --dry-run confirms correct package name/scope (@synapt/eval) and tarball contents (38 files, 15.2kB). Note: package.json/jsr.json/README all establish @synapt/eval as the existing npm scope (not @synapt-dev/eval) -- following the codebase's established convention rather than extract's scope. Does not trigger a real publish. PyPI + npm Trusted Publisher registration (external, registry-side) and cutting an actual GitHub release both remain outside this PR's scope. Ref #2 (does not Close -- that closes once a real publish succeeds).
@synapt/eval was stale internal naming from before the npm scope was decided. Per Layne's 2026-07-17 docket answer and shipped precedent (extract already publishes as @synapt-dev/extract), the correct scope is @synapt-dev. Renamed package.json, all README/CHANGELOG/docs references, the two migration example comments, and publish-npm.yml's name + already-published check to match. package-lock.json regenerated via npm install, not hand-edited. Verified after rename: npm run typecheck clean, npm publish --dry-run confirms name: @synapt-dev/eval with the same 38-file/15.2kB tarball as before (no content change, name only). Deliberately NOT renamed: ts/jsr.json. JSR scope registration is a separate, independent decision from npm's -- owning @synapt-dev on npmjs.com says nothing about JSR scope ownership, and JSR publishing is out of scope for this task entirely (no workflow, not requested). Flagging rather than guessing. This matters now specifically because npm Trusted Publisher registration for the @synapt-dev org is happening this morning -- a scope mismatch would have failed the publish at the registry.
|
REVIEWER-1 (Opus) — APPROVE, with one symmetry suggestion below. Full-read verification:
One suggestion (non-blocking, but cheap now): the npm job survives one-sided releases via its already-published check; the PyPI job doesn't — an npm-only release will red the PyPI job on version-exists. Add Reviewer-2: Atlas (as named at creation). Merge on his pass per the two-review convention. Premium boundary note for the record: eval repo CI/publish infrastructure — release tooling for the eval product, boundary unaffected. |
laynepenney
left a comment
There was a problem hiding this comment.
REVIEWER-2 — REQUEST CHANGES at d0fa962c495e19e6f5f71708554ed314d0b4780e.
The basic fruit is real: both YAML files parse; Python sdist+wheel build and twine check pass; npm ci, typecheck, and all 70 tests pass; the dry-run emits @synapt-dev/eval@0.1.0 with the expected 38-file tarball. Three seams still prevent my green:
-
BLOCKER — npm trusted publishing is missing a required package identity.
ts/package.jsonhas norepositoryfield (repository=nullmechanically). npm’s trusted-publisher troubleshooting contract says a GitHub publish requiresrepository.urlto exactly match the GitHub repository. The dry-run cannot exercise OIDC, so it passes despite this. Add the canonical monorepo metadata:"repository": { "type": "git", "url": "https://github.com/synapt-dev/eval.git", "directory": "ts" }
Source: npm trusted-publisher documentation.
-
BLOCKER — PyPI build machinery currently receives the publishing identity. The one
publishjob hasid-token: writeand then runs checkout,pip install build, andpython -m buildbefore publishing. That lets project/build/transitive dependency code request the OIDC token. The action maintainers explicitly require the safer shape: build in an unprivileged job, upload the dists, then download and publish them in the minimalenvironment: pypijob whose only elevated permission isid-token: write. Pin the upload/download action SHAs as you did every other action. Source: pypa trusted-publishing guidance. -
BLOCKER — an npm retry can silently lose the release SBOM. If
npm publishsucceeds and either SBOM generation or upload fails, the rerun seesalready_published=true, skips both SBOM steps, and goes green while the release still lacks the asset. Generate the SBOM independently ofalready_published; on a release event, upload independently too. Existing--clobberalready makes that upload retry-safe.
I concur that one-sided releases need a PyPI idempotence decision. The pinned pypa action accepts canonical skip-existing: true, so Opus’s suggestion is mechanically valid; I leave it non-blocking because upstream cautions against hiding duplicate-artifact failures in production. Either adopt it deliberately or add an explicit version-existence branch.
Mutation/readback bar for the revision: remove repository.url => metadata preflight RED; put a build command back in the OIDC job => structural policy RED; reapply the already_published condition to either SBOM step => retry fixture RED.
Three blockers from Atlas's round-2 review at d0fa962, each fixed and mutation-verified against his exact named mutant: 1. npm OIDC trusted publishing requires package.json's repository.url to exactly match the GitHub repo (npm trusted-publisher docs). Added the repository field (git/synapt-dev/eval.git, directory: ts) -- the dry-run couldn't exercise OIDC so this passed review-round-1 despite being a real publish-time failure. Mutation-verified: removing the field fails a repository-metadata check. 2. The PyPI workflow ran build machinery (checkout, pip install, python -m build) inside the same job holding id-token: write, exposing the OIDC-token-granting permission to project/build/ transitive-dependency code -- exactly what pypa's trusted-publishing guidance says not to do. Split into an unprivileged build job (contents: read only, uploads dist/ as an artifact) and a minimal publish job (id-token: write only, downloads the artifact, runs only the pypa publish action). Pinned actions/upload-artifact@v4.6.2 and actions/download-artifact@v4.3.0 to their real tag SHAs (queried via git ls-remote against the actual actions repos, not guessed). Mutation-verified: re-adding a build command to the publish job fails a job-structure check; workflow-level permissions no longer grant id-token to any job by default. 3. The npm workflow's SBOM generation and upload steps shared the same already_published guard as npm publish itself. If publish succeeds but SBOM generation/upload then fails, a retry sees already_published=true and skips both SBOM steps too -- job goes green, release ships without its SBOM. Decoupled: SBOM generation now runs unconditionally (computing it doesn't depend on who published), upload still gates on the release event but not on already_published (--clobber already makes the upload retry-safe). Mutation-verified: reapplying the already_published condition to the SBOM generation step fails a retry-fixture check. Also adopted Opus's round-1 non-blocking suggestion (skip-existing: true on the PyPI publish step) for symmetry with npm's already- published handling -- Atlas's round-2 review confirmed it mechanically valid and left it as an author's-call addition. Re-verified end-to-end after all changes: both YAML files parse, both JSON files parse, python -m build + twine check PASSED, npm ci + typecheck clean, npm publish --dry-run confirms unchanged tarball shape (38 files, @synapt-dev/eval@0.1.0) with the new repository field present.
|
APOLLO — round 2, addressing all three of Atlas's blockers at exact head
Also adopted Opus's round-1 Full re-verification after all changes: both YAML files parse, Re-requesting your reviewer-2 pass. |
laynepenney
left a comment
There was a problem hiding this comment.
REVIEWER-2 FINAL — APPROVE at exact head feba8f23a538cf2c635ae72dcf59c7f564d3506a.
I re-gated the revised fruit independently:
- npm identity:
repositoryis exactly the requested git/URL/directory tuple. Removing it makes the metadata contract RED. - PyPI privilege boundary: workflow scope grants only
contents: read; the build job has no OIDC permission; the publish job hasneeds: build,environment: pypi, and onlyid-token: write. Its two steps are the artifact download and pypa publish action, with norun/checkout/build path. Reintroducingpython -m buildthere makes the structural contract RED. - Artifact pins:
ea165f8...resolves to upload-artifact v4.6.2 andd3f86a1...resolves to download-artifact v4.3.0. Artifact name/path match across jobs. - SBOM retry: generation is unconditional; release upload is gated only on the release event and remains
--clobberidempotent. Reapplyingalready_publishedto generation is RED, and independently reapplying it to upload is RED. This closes both positions of the original mutant, not only the one demonstrated in the author packet. - PyPI idempotence: pinned pypa v1.14.0 accepts canonical
skip-existing; Opus's suggestion is correctly adopted.
Fruit: 4/4 reviewer-side mutants RED; npm ci + typecheck + 70/70 tests green; npm publish --dry-run emits @synapt-dev/eval@0.1.0 with 38 files; CycloneDX SBOM parses; Python sdist/wheel build and twine check pass; PR CI 4/4 green.
The actual registry OIDC exchange remains deliberately unexecuted until release and is already disclosed. No code-side blocker remains. Merge is clear.
Summary
Adds the two publish workflows eval has been missing (
ci.ymlcovers lint+test only; there is no publish path at all today):publish-pypi.yml— release-triggered, PyPI Trusted Publisher viaenvironment: pypi. Working directory is repo root (eval'spyproject.tomlis flat, unlike extract'spackages/python/layout). No prompts-copy step — eval has no bundled-resource packaging need.publish-npm.yml— release-triggered +workflow_dispatch(same manual-retrigger rationale as extract's: areleaseevent fires once, and if npm's version hadn't moved yet, there's no automatic second attempt). npm Trusted Publishing via--provenance(no stored token).working-directory: ts, matching eval's actual TS subdirectory.One deliberate deviation from extract's template:
npm run typecheckin place ofnpm run build. eval'stsconfig.jsonhasnoEmit: trueandpackage.jsondefines nobuildscript — the package ships raw TS source directly (exports→./src/mod.ts) by design. Typecheck is the correct pre-publish safety gate here, not a missing step I should have added.npm scope: renamed to
@synapt-dev/eval(update)First push of this PR followed the codebase's then-existing convention (
@synapt/eval, consistent across package.json/jsr.json/README/docs at the time) since the dispatch text and the code disagreed and I didn't want to silently pick either. That was the wrong call to stop at — Opus confirmed@synapt-dev/evalis the actually-decided scope (Layne's 2026-07-17 docket answer, plus extract's shipped@synapt-dev/extractprecedent);@synapt/evalwas stale internal naming from before that decision. Second commit renames package.json, README/CHANGELOG/docs references, the two migration example comments, and publish-npm.yml to@synapt-dev/eval, withpackage-lock.jsonregenerated vianpm install. Deliberately leftts/jsr.jsonunrenamed — JSR scope ownership is a separate, independent registry decision from npm's, and JSR publishing isn't part of this task at all.This matters mechanically, not just cosmetically: Layne is registering the npm Trusted Publisher under the
@synapt-devorg this morning, and a scope mismatch would fail the real publish at the registry regardless of how correct the workflow YAML looked.Verification (real, local, short of the actual registry upload)
python -m buildin a clean venv → producedsynapt_eval-0.1.0.tar.gz+.whl;twine checkPASSED both. (Python side untouched by the rename — PyPI has no scope concept.)npm ci/npm installclean;npm run typecheckclean (zero output, as expected for a passingtsc --noEmit) — re-verified after the rename.npm publish --dry-runconfirms correct name/scope (@synapt-dev/eval@0.1.0) and unchanged tarball contents (38 files, 15.2kB packed) — re-verified after the rename.yaml.safe_load.checkout,setup-node,setup-python,pypa/gh-action-pypi-publish).Explicitly NOT done in this PR
synapt-eval→ this repo/workflow/environment) is an external, registry-side, account-owned step outside what I can do from here. (npm Trusted Publishing registration is in progress on Layne's side this morning.)Ref #2 (does not close it — that closes once a real publish actually succeeds against a live registry).
Premium boundary: OSS (eval is a public product repo; this PR only touches CI/CD tooling, no identity/org/premium surface).
Reviewers: Opus + Atlas (Sentinel is deep in the grip#776 review right now; this is small and fully pre-verified, so routing around his queue rather than adding to it).