Concepts
Evaluating piot for a migration? Read Known gaps alongside this page — it enumerates non-goals, behaviours that commonly get misread as gaps, and outstanding limitations.
What piot covers
putitoutthere (piot) is a polyglot registry publisher. Given artifacts on disk and credentials via OIDC, it publishes to crates.io, PyPI, and npm — in topological order, idempotently, with completeness checks so partial publishes are rare. That's the whole tool.
In scope:
- Compute which of your declared packages need to ship on a given merge (cascade via glob
paths+ transitivedepends_on). - Bump each package's version from a
release:commit trailer. - Publish in
depends_ontopological order (Rust crate before the PyO3 wheel that depends on it, etc.). - OIDC trusted publishing to all three registries (crates.io, PyPI, npm) plus long-lived-token fallback.
- Per-registry idempotent pre-check (
GETthe registry beforepublish; skip if already there). - npm platform-package families — per-platform sub-packages (
{name}-{target}) with narrowedos/cpu/libc, plus a top-level package whoseoptionalDependenciespin them. Triggered bybuild = "napi"orbuild = "bundled-cli". Details in npm platform packages. - Per-target runner selection via object-form
targetsentries ({ triple, runner }). The planner emits the selected runner into the build-job matrix. See Configuration → Target entries. - Declared trust-policy validation:
[package.trust_policy]inputitoutthere.toml, thendoctordiffs against the workflow file (always),GITHUB_WORKFLOW_REF(in CI), and the crates.io registry (whenCRATES_IO_DOCTOR_TOKENis set). See Authentication. - Create a git tag per package (
{name}-v{version}) and a GitHub Release.
Explicitly out of scope (composed from other tools, not absorbed):
- Build-side compilation. piot accepts a
runnerhint per target and emits the matrix, but your workflow'sbuildjob runsmaturin build,napi build,cargo build, etc. piot doesn't execute the compile step. - Version computation from commit content. Use
release-please,release-plz, orchangesetsto set{name, version}, or use therelease:commit trailer. piot does not diff commits to infer semver. - Standalone binary archive uploads to GitHub Releases (
.tar.xz/.tar.gz/ curl-installable tarballs). That iscargo-dist's andgoreleaser's territory; compose with them. - Shell hooks / plugin APIs. No
pre_publish, nopost_tag. Run custom steps in your workflow around piot, not through config. - Monorepo discovery. Packages are declared explicitly via
[[package]]entries. No directory walking. - Changelogs. Delegate to
release-pleaseor similar. - Auto tag-rollback on partial-publish failure. crates.io is immutable; deletion isn't a safe undo. piot relies on the pre-publish completeness check to prevent the class of failure that would motivate rollback.
Full non-goals list: notes/design-commitments.md.
The loop
Every push to main triggers the release workflow, which runs three jobs:
- plan — compute which packages need to ship and at what version. Output: a JSON matrix.
- build — fan out across the matrix. User-owned build steps produce the artifacts.
- publish — per package: write version file, run the handler's publish, create a git tag, create a GitHub Release.
piot's surface is plan + publish — build is yours
The piot Action exposes two command: values: plan and publish. There is no command: build. The middle job is your workflow's responsibility — your build step produces the artifacts and uploads them under the names piot's plan job emitted on each matrix row (matrix.artifact_name, matrix.artifact_path). The publish job then reads those artifacts off disk via actions/download-artifact.
If you're hand-rolling the workflow or migrating from a different shape, run npx putitoutthere init in a scratch directory first and treat the scaffolded release.yml as the canonical reference. The 3-job plan → build → publish structure (with upload-artifact / download-artifact between them) is part of piot's contract; piot's publish-side completeness check assumes it. See the artifact contract for the file-layout half of that contract.
What runs on which event
The scaffolded workflows run different subsets of the loop depending on the event that triggered them. Knowing the table prevents the most common false-positive: assuming a green run on a PR is a release.
| Event | Workflow | plan | build | publish | tag + Release |
|---|---|---|---|---|---|
push: branches: [main] | release.yml | ✅ | ✅ | ✅ | ✅ |
pull_request | putitoutthere-check.yml | ✅ | — | — | — |
pull_request (if release.yml runs) | release.yml (publish step gated) | ✅ | ✅ | skipped | — |
workflow_dispatch (dry_run: true) | release.yml | ✅ | ✅ | skipped | — |
workflow_dispatch (dry_run: false) | release.yml | ✅ | ✅ | ✅ | ✅ |
schedule: | release.yml | ✅ | ✅ | ✅ | ✅ |
The publish step is gated on github.event_name != 'pull_request' (and on dry_run != true). A green PR-event run validates the plan
- build halves of your pipeline — it does not indicate that anything was published.
The signal of a real release is a tag push ({name}-v{version}, or your tag_format) plus a GitHub Release on the Releases page. Workflow-run success alone is necessary but not sufficient. See Testing your release workflow for how to deliberately exercise the publish path before the next natural release.
Cascade
Every package declares paths — globs that say "these files belong to me." When you merge a commit that touches any of those globs, the package cascades into the plan.
If another package declares depends_on = ["this-package"], that downstream also cascades. Transitively. DFS-ordered, with cycle detection at config-load time.
Trailer
The default behavior is patch bump on cascade. To override, add a release: trailer to the merge commit:
release: minorOr scope it to specific packages:
release: major [my-crate, my-cli]See trailer guide for the full grammar.
Publishing order
Inside a single release, packages publish in topological order of their depends_on graph. If your Python wrapper depends on a Rust crate, crate publishes first.
Idempotency
Every handler's first move is isPublished — check the registry for the target version. Already there? Skip cleanly. Lets you re-run failed releases without fighting the registry's immutable-publish semantics.
Packaging shapes
Each [[package]] declares a kind (crates / pypi / npm) and, for some kinds, a build mode that picks a packaging shape piot knows how to publish. The build value is declarative: it tells piot what to do at publish time, not how to compile. Producing the binaries is your workflow's job.
kind = "crates"— plaincargo publish.kind = "pypi"withbuild = "setuptools" | "hatch" | "maturin"— sdist + wheel from an existing manifest.kind = "npm"vanilla (nobuild) — single-packagenpm publish --provenance.kind = "npm"withbuild = "napi" | "bundled-cli"— platform-package family (per-platform{name}-{target}sub-packages + a top-level withoptionalDependenciespinning them). See npm platform packages.
Dirty working tree
putitoutthere rewrites the version field in each package's manifest (Cargo.toml, pyproject.toml, package.json) right before publishing. That edit is intentional and not committed — the release tag points at the unmodified merge commit (see cascade).
For the crates handler, that means cargo publish --allow-dirty is required. Before invoking cargo, putitoutthere scans the working tree and refuses to proceed if anything is dirty outside the managed Cargo.toml — that narrow scope restores cargo's default safety net without blocking the managed bump.
If you run putitoutthere publish locally outside a git work tree (e.g. in a snapshot directory), this guard falls through silently and cargo's own --allow-dirty semantics take over. Prefer running publishes from inside a checked-out repo.