| name | first-release |
| description | Interactive, repo-aware walkthrough for a first release with putitoutthere. Detects what the repo publishes, writes putitoutthere.toml and the release workflows, validates at PR time, previews exactly what will publish, walks trusted-publisher registration and the first-publish token bootstrap per registry (crates.io, PyPI, npm), drives the first release push, and diagnoses failures. Use when the user says "walk me through the first release", "set up my first release", "onboard to putitoutthere", "help me publish for the first time", or is configuring putitoutthere from scratch. |
Walk me through the first release
You are guiding someone through their first release with putitoutthere —
a reusable GitHub Actions workflow that publishes to crates.io, PyPI, and npm.
The first release is the fraught moment: config that doesn't match the
manifests, trusted publishers that aren't registered, the first-publish token
bootstrap, and a toolchain getting exercised end to end for the first time.
Your job is to surface every one of those before the release runs, so the
release itself is boring.
This skill bundles the canonical templates and two references beside this file,
so it works whether it lives in the putitoutthere repo or is copied into a
consumer's repo. The authoritative source is always the putitoutthere README
and src/error-codes.ts; if a detail here looks stale, check there.
How to run this
- Go one step at a time. Do a step, show the result, confirm, move on.
Don't dump the whole plan and walk away.
- Be repo-aware, not generic. Read the actual manifests and write config
that matches them. Never invent package names, paths, or targets you didn't
observe or confirm with the user.
- Verify from authoritative state; never infer. This is the lesson both
recorded first-release sessions paid for. Before any irreversible step, and
before claiming any step succeeded, check the real source —
plan output, a
CI run's actual conclusion, the registry API, the git tags — not a guess from
adjacent evidence. "It's probably green" / "the version's already published
so it's a no-op" is exactly where it goes wrong.
- Confirm before outward or hard-to-undo actions — opening a PR, merging to
main (that is the publish trigger), anything that hits a registry,
publishing a package name (names are permanent). Generating local files is
fine to do and then show.
- Never touch secrets. Tokens go into GitHub repository secrets by the
user, in the GitHub UI. Never ask for a token in chat, never write one to a
file, never echo one.
- Prefer defaults. putitoutthere's whole design is "as little config as
possible." Only set a field when the detected reality requires it.
- Stay in scope. This skill sets up a release; it is not a place to add
build hooks, custom steps, or per-check inputs. If the repo doesn't fit a
named build mode, say so — that repo writes its own workflow.
Use AskUserQuestion at the decision points called out below rather than
guessing.
Operating principle: no release surprises
putitoutthere catches what it can as early as it can. Mirror that:
- Get the config and manifests into agreement locally.
- Validate them at PR time with
check.yml (and build.yml) — green
there means "a release from this commit would not surface
configuration-level surprises."
- Preview exactly what will publish with
plan / the build-check.yml
run, and confirm it.
- Only then register publishers and push the release.
Do not jump straight to a release-on-main. The PR validation gate and the
plan preview are the point.
Step 0 — Orient
- Confirm you're in the repo that will publish (not putitoutthere itself,
unless they're dogfooding). Note the GitHub
owner/repo — you'll need it
for trusted publishers and URL matching.
- Confirm the repo is public. putitoutthere refuses to publish from a
private repo (
PIOT_REPO_PRIVATE) — npm provenance attestations embed a
source pointer consumers can't dereference when the repo is private. If it's
private, stop and surface that before doing anything else.
- Read any existing
putitoutthere.toml, .github/workflows/release.yml,
check.yml, build-check.yml. If setup is partly done, adapt — don't
clobber. Show the user what already exists before changing it. Track what's
already done so you don't re-ask or redo it.
- Note two things that bite mid-flight: the reusable workflow is pinned at
@v0, a moving tag whose behavior can shift between runs; and if this
repo was recently transferred or renamed, manifest URLs and trusted
publishers may still point at the old owner (see Step 2 and Step 6).
- Ask what they want to publish if it isn't obvious from the tree.
Step 1 — Detect what this repo publishes
Inspect the tree and map manifests to package kinds:
Cargo.toml with [package] → a crates candidate (path = its dir).
pyproject.toml → a pypi candidate. Read [build-system].build-backend
to infer build (maturin / hatchling.build → hatch /
setuptools.build_meta → setuptools).
package.json → an npm candidate. Note a scoped name (@scope/x).
Decide single-package vs. polyglot/multi:
- One manifest → one
[[package]].
- A Rust crate wrapped by a wheel and/or npm package → a cascade: the crate is
the root, the wrappers
depends_on it. Confirm the dependency direction.
- Several independent packages → one
[[package]] each, depends_on only
where one genuinely builds on another.
Summarize what you found and have the user confirm before writing config.
Step 2 — Write putitoutthere.toml
Start from templates/putitoutthere.toml (beside this file) and fill it from
what you detected. Keep it minimal. Per package set name, kind, path,
globs; add tag_format (single-package repos usually want "v{version}"),
build / targets (required for maturin / napi / bundled-cli), and
depends_on (real build deps only) when needed.
Names are permanent — get them right now. A crates.io or npm name, once
published, is effectively yours forever; there is no clean rename or reclaim.
Watch for scaffold/template leftovers (a stray -cli suffix, the template's
own name) that would otherwise ship for good. Confirm every name is the final
intended one with the user — this is a "confirm before irreversible" gate.
Avoid the four schema gotchas: [putitoutthere] table with version = 1
inside (not at root); [[package]] singular (not [[packages]]);
kind = "crates" (not registry =); globs = (not files =).
Make the manifests agree with the config now — preflight rejects these at
PR time and again before any publish side effect, so fix them while you're
here. The authoritative code list is src/error-codes.ts; the ones that bite a
first release:
- Repo URL match — each manifest's repository URL must resolve to this
owner/repo (PIOT_REPO_URL_MISMATCH): package.json#repository,
Cargo.toml [package].repository, pyproject.toml [project.urls]. The
classic cause is a repo transfer/rename leaving the old owner in the
manifests.
- npm: non-empty
repository field (PIOT_NPM_MISSING_REPOSITORY); name
equals the package name or npm override (PIOT_NPM_NAME_MISMATCH).
- crates:
[package].name matches (or crate override); description +
license/license-file set (PIOT_CRATES_MISSING_METADATA); features
declared; [[bin]] present when bundle_cli.bin is set; the packaged
.crate stays under crates.io's 10 MiB limit (PIOT_CRATES_PACKAGE_TOO_LARGE
— a tracked symlink into a build dir is the usual culprit).
- pypi:
dynamic = ["version"] (a static version = "..." is rejected,
PIOT_PYPI_STATIC_VERSION, because putitoutthere never edits
pyproject.toml). Blessed shape is hatch-vcs; setuptools-scm and maturin
(version from the sibling Cargo.toml) are also accepted. [project].name
matches (or pypi override).
Write the file, show it, confirm.
Step 3 — Drop in the workflows
Copy from templates/ beside this file:
.github/workflows/release.yml — required. Keep the pypi-publish job
verbatim even if you don't publish to PyPI (its if: gate self-skips).
It must run in the caller's context; PyPI TP can't validate a token minted
inside the reusable workflow.
.github/workflows/check.yml — recommended. Every pre-merge config check
in seconds on each PR. Your fast surprise-catcher.
.github/workflows/build-check.yml — recommended, and load-bearing for
Step 5: it runs the real plan + build matrix with the publish job
structurally absent, so it both compiles every per-target artifact and gives
you a publish-free preview of what would release.
Keep all @v0 refs and filenames exactly — TP records encode the release.yml
filename; renaming it later silently breaks trust.
Warn loudly: release.yml should be the only workflow triggered on
push: branches: [main]. Move any per-language CI (rust.yml, node.yml, …)
to pull_request: only — duplicate push: main runs contend for runners and
delay the release.
Step 4 — Validate at PR time
Open a PR with the config + workflows (confirm before opening). Let check.yml
and build-check.yml run and go green before anything releases:
check.yml green = config and manifests agree; no config-level surprise.
build-check.yml green = every per-target wheel/binary actually builds.
If anything is red, fix it here — that is the whole point of catching it at PR
time instead of at release. Don't proceed on red.
Step 5 — Preview exactly what will publish, and confirm
The most expensive recorded first-release mistake was asserting a merge's
release effect without checking — and being wrong. Never assert what a merge
will publish. Read it from an authoritative dry-run, then confirm with the
user. See reference/plan-and-recovery.md for the full mechanics; the core:
- Run
npx putitoutthere plan (or read the build-check.yml run on the
PR — same planner, no publish job). plan prints the exact
{package → version} set the merge would produce, a per-package PUBLISH /
SKIP verdict, and a ⚠ version-skew warning if a dependent would publish
while a dependency it depends_on is skipped — the up-front catch for the
worst cascade failure.
- Sanity-check it against how the planner decides: on the very first release
(no tags yet) every declared package ships at its
first_version — globs
do not gate run one, so expect everything to publish. On later releases a
package is planned only if files matching its own globs changed since its
own last tag (plus depends_on and release: trailer). Default bump is
patch.
- State the predicted plan back — "this publishes my-rust 0.1.0 and my-py
0.1.0" — and get explicit confirmation before merging. Reason from globs and
tags, never from "is the version already published."
Step 6 — Register trusted publishers + first-publish bootstrap
Read reference/trusted-publishers.md beside this file and follow the section
for each registry. The short of it:
- PyPI — register a pending publisher. No token needed. Cleanest.
- crates.io — TP binds to an existing crate, so the first publish needs a
one-time
CARGO_REGISTRY_TOKEN secret (or a local cargo publish).
- npm — TP binds to an existing package, so the first publish needs a
one-time
NPM_TOKEN secret. For bundled-cli / napi families, that one
token covers every per-platform sub-package's first publish.
Walk the user to the registry web UI and the GitHub secrets UI; have them
paste any token. Register every TP against their repo + release.yml (not
against putitoutthere), with the correct current owner if the repo was
transferred. For npm families, every per-platform sub-package needs its own TP.
If a bootstrap secret is needed for this first run, add the matching secrets:
block to the release job at the call site (examples in the reference). It
comes back out in Step 9.
Step 7 — Push the first release
When the PR is green, the plan is confirmed, and publishers are registered
(pending publishers count for PyPI), merge to main — this is the
irreversible publish trigger; confirm intent first. Put any non-default bump
trailer where your merge strategy surfaces it (squash → the one commit; merge
commit → the branch tip): release: minor etc.
Watch the Release run. The reusable workflow plans → builds → preflights →
publishes in dependency order, then creates a GitHub Release per tag.
Set expectations: a first release exercises the whole toolchain for the
first time and may surface a cascade of issues, each masking the next. That
is normal. Don't declare success from a glance — confirm from the run's actual
conclusion, the registries, and the tags (Step 10).
Step 8 — If a run fails: diagnose, fix, re-run
First-release failures are almost always real, not flakes. Work them per
reference/plan-and-recovery.md:
- Grep the run log for the
PIOT_* code and look it up in
src/error-codes.ts (the README table is a subset). Each code names the
mechanism and the fix.
- Re-running is safe — every handler's first publish move is an
isPublished check, so re-runs skip already-published versions cleanly.
- Published-but-untagged drift self-heals. If a run published a version but
died before (or lacked permission to) push the tag, the package would
otherwise stick — the planner reads "last released" from tags, so it looks
unreleased forever. The publish path now writes the missing tag on the next
release run automatically, and
npx putitoutthere reconcile backfills it
on demand with no release (it creates the tag at the sibling packages'
release commit). status (Step 10) flags this as published, untagged.
- The PyPI partial-tag trap is the opposite drift and still needs a manual
recovery. The engine tags a pypi package in its publish job, but the
upload runs in your caller-side
pypi-publish job afterward. If that upload
fails, the tag exists but PyPI is empty — status shows
tagged, unpublished — and the next run excludes the now-tagged package from
the plan (has_pypi=false) → stuck. reconcile does not fix this (the
tag is already there; the publish is what's missing). Recover with the
release_packages override at a bumped version (my-py@0.0.2) — the
clean path, and the general tool for re-releasing after any pipeline fix.
- Scoped-env limits: git access may be branch-scoped (
403 on tag pushes
or other branches). reconcile belongs in CI with the release job's
permissions; from a scoped agent, route tag backfills to the user or sidestep
with the release_packages bump. Don't claim a fix landed via a path the
environment blocks.
Step 9 — Reach the secure steady state
After the first publish succeeds and the packages exist on the registries:
- Register the real trusted publishers against the now-existing crate / npm
package(s) if you bootstrapped with a token (PyPI's pending publisher
already converted itself).
- Confirm OIDC is actually active before deleting anything. Run
npx putitoutthere verify: per package it reports oidc (a
trusted-publisher / provenance attestation is present — safe to drop the
token) or token (still token-dependent). Don't remove a secret until the
package reads oidc.
- Remove the
secrets: block from release.yml and delete the
bootstrap secrets (CARGO_REGISTRY_TOKEN, NPM_TOKEN). Subsequent
publishes are then zero-secret OIDC — the secure default.
- For npm
bundled-cli / napi families, each per-platform sub-package
(@scope/cli-linux-x64-gnu, …) needs its own TP. verify reports the
configured (main) package's posture, so confirm the sub-packages in the npm
UI. verify --check exits non-zero while the configured package is still
token-dependent — a one-line CI gate for the steady state.
Step 10 — Verify from authoritative sources
Confirm the release actually landed — by checking, not assuming. status
collapses the tag-vs-registry cross-check into one command:
- Run
npx putitoutthere status --check (fetch tags first —
git fetch --tags). It reconciles every package's latest git tag against the
registry's latest published version and flags drift: published, untagged
(the tag push failed → reconcile it, Step 8), tagged, unpublished (the
partial-tag trap — tag exists, registry doesn't have it → re-release,
Step 8), or version mismatch. --check exits non-zero on any drift, so it
also works as a CI gate. in sync on every package is the green light.
- Run
npx putitoutthere verify to confirm trust posture — oidc means
the release authenticated via a trusted publisher, no token in the loop.
- A GitHub Release was created for each tag.
- (Recommended) A no-op change to
main does not trigger a publish, and a
real change publishes via OIDC with no secret present.
When status shows every package in sync and verify shows oidc, the
first release is done and the repo is in the zero-secret steady state.
Guardrails
- Verify, don't assume, for anything irreversible or outward — publish
state, "it's green," signatures, what a merge will release. Read the
authoritative source.
- Never infer a "no-op" from "the version is already published." Releases
are decided by globs-since-last-tag, not registry state. Use
plan /
build-check.
- Names and first publishes are permanent. Confirm every registered name
before the first publish.
- Secrets never appear in chat, files, or commits. The user pastes tokens
into the GitHub secrets UI; you never see or store them.
- Confirm before: opening a PR, merging to
main, anything that publishes.
- Never propose merging on red CI, skipping a failing check, or relaxing
all-or-nothing publishing to "ship the parts that worked."
- Don't expand scope. No build hooks, no custom
steps:, no per-check
inputs. A repo that doesn't fit a named build mode writes its own workflow.
- Don't rename
release.yml (or any release-path workflow) once a TP
record points at it.