Skip to content

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 + transitive depends_on).
  • Bump each package's version from a release: commit trailer.
  • Publish in depends_on topological 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 (GET the registry before publish; skip if already there).
  • npm platform-package families — per-platform sub-packages ({name}-{target}) with narrowed os / cpu / libc, plus a top-level package whose optionalDependencies pin them. Triggered by build = "napi" or build = "bundled-cli". Details in npm platform packages.
  • Per-target runner selection via object-form targets entries ({ triple, runner }). The planner emits the selected runner into the build-job matrix. See Configuration → Target entries.
  • Declared trust-policy validation: [package.trust_policy] in putitoutthere.toml, then doctor diffs against the workflow file (always), GITHUB_WORKFLOW_REF (in CI), and the crates.io registry (when CRATES_IO_DOCTOR_TOKEN is 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 runner hint per target and emits the matrix, but your workflow's build job runs maturin build, napi build, cargo build, etc. piot doesn't execute the compile step.
  • Version computation from commit content. Use release-please, release-plz, or changesets to set {name, version}, or use the release: 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 is cargo-dist's and goreleaser's territory; compose with them.
  • Shell hooks / plugin APIs. No pre_publish, no post_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-please or 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:

  1. plan — compute which packages need to ship and at what version. Output: a JSON matrix.
  2. build — fan out across the matrix. User-owned build steps produce the artifacts.
  3. 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.

EventWorkflowplanbuildpublishtag + Release
push: branches: [main]release.yml✅✅✅✅
pull_requestputitoutthere-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: minor

Or 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" — plain cargo publish.
  • kind = "pypi" with build = "setuptools" | "hatch" | "maturin" — sdist + wheel from an existing manifest.
  • kind = "npm" vanilla (no build) — single-package npm publish --provenance.
  • kind = "npm" with build = "napi" | "bundled-cli" — platform-package family (per-platform {name}-{target} sub-packages + a top-level with optionalDependencies pinning 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.

Released under the MIT License.