Skip to content
Back to skills

Release Procedure

ASecurity

Use when cutting a release or publishing anything to a public surface — GitHub release, PyPI, the MCP registry, the GHCR container images, or the Claude Code plugin marketplace. Covers the docs currency pass, the seven-file version cut, build/inspect, the Trusted Publishing + registry + image automation, and verification.

  • 6 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added October 23, 2026
ai-agentspythonrustgodockergitapisecuritydocumentation

Works with

  • claude code
  • cli
  • api
  • mcp

Security analysis

A100/100

Scanned November 11, 2026

npx -y skills add Pseudogiant-xr/Pseudolife-MCP --skill release-procedure --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Release Procedure?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Release Procedure
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/pseudogiant-xr-release-procedure/badge)](https://www.skillsdirectory.com/skills/pseudogiant-xr-release-procedure)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: release-procedure
description: Use when cutting a release or publishing anything to a public surface — GitHub release, PyPI, the MCP registry, the GHCR container images, or the Claude Code plugin marketplace. Covers the docs currency pass, the seven-file version cut, build/inspect, the Trusted Publishing + registry + image automation, and verification.
---

# Release / publish procedure (five public surfaces)

GitHub releases, PyPI, the MCP registry, the GHCR container images, and the
Claude Code plugin marketplace (`.claude-plugin/marketplace.json` +
`plugin/` — served straight from master, no publish step; users pull via
`/plugin marketplace update`) all serve from this repo; a release touches
them in this order (first done 2026-07-16, v0.8.0; GHCR images added
2026-08-14).

0. **Docs currency pass before the cut** — three checks, and (a) is the one
   that gets skipped because nothing fails when you miss it.

   **(a) Absence — is the new behavior documented at all?** List the
   behavior changes since the last tag (`git log vN.N.N..HEAD`, the
   CHANGELOG's `[Unreleased]` plus the pending `changelog.d/` fragments) and
   ask of each: *which user-facing page
   describes this?* A capability with no guide entry contradicts nothing,
   so no guard test and no re-verify pass will ever surface it — only this
   question will. Schema v16–v18 shipped undocumented exactly this way, and
   0.10.0's headline extraction change lived in the CHANGELOG alone until
   it was caught by hand at the release gate. A CHANGELOG entry is a record
   of a change, not documentation of a behavior. Corollary: never exclude a
   file from this pass on the strength of an earlier read that was looking
   for something else — a narrow check does not justify a broad exclusion.

   **(b) Contradiction — do the existing claims still hold?** The guard
   tests pin numbers (schema, identifiers), but *framing* drifts silently:
   the 2026-07-16 pass found 15 stale claims the guards can't see.
   Re-verify the drift-prone claim classes against code before any release:
   what's bundled/default (extractor model + size, embedding weights),
   the transport story (HTTP-first; shim = host-process only), lifecycle
   ownership (episodes are daemon-owned; briefing is the only hook),
   tool count/tiers and the hidden-tools-need-expand rule, shipped config
   defaults (surprise gate is permissive), image/install sizes, and any
   "can't / doesn't / no X" absolute — those age worst. **Translated front
   doors** (`docs/i18n/README.{zh,ja,ko,pt-br,es}.md`): if the narrative or
   quickstart in `docs/i18n/README.source.md` changed, bump its
   `i18n-source` version and re-run the translation subagents — the guard
   (`tests/test_i18n_readme.py`) pins code blocks + sync markers but cannot
   read prose. Surfaces: README,
   **docs/guide/*.md** (the user-facing guide pages the 2026-07-16
   restructure moved the README's deep material into — configuration,
   retrieval, dreaming, episodes, memory-model, benchmarks; they carry the
   same drift-prone claims the README used to),
   CONTRIBUTING, SECURITY, evals/README, examples/ (CLAUDE.memory.md is
   injected into user CLAUDE.md / AGENTS.md files — its tool surface must match exactly),
   docs/runbooks, ops/.env.example comments. The README is the PyPI
   description, so its fixes only reach PyPI at the next version.

   **(c) Setup — does any change since the last tag leave the user a manual
   step?** For each behavior change, ask what a user must do by hand after
   `pseudolife-mcp update` or a fresh install: a config edit, a login, a
   serve, a command to run once. If the answer is anything, the release
   waits until the installer or update does it (CLAUDE.md, "Install and
   update experience"). The 2026-10-04 passkey and test-login changes held
   the release after 0.16.1 this way.

1. **First fold the changelog fragments**: `python ops/assemble_changelog.py`
   moves every `changelog.d/*.md` entry into `## [Unreleased]` (newest first)
   and deletes the fragment files. Review the folded section, then commit it
   with the cut. Fragments landed since 2026-10-09 exist only there until this
   runs.

   **Version cut touches seven files together**: the CHANGELOG (`## [N.N.N]`
   header over `[Unreleased]` — one fragile line; the tag↔section guard test
   exists because an adjacent edit once deleted it silently), `pyproject.toml`,
   the daemon image tag in **both** compose files (`ops/docker-compose.yml`
   and the packaged copy `pseudolife_memory/compose/docker-compose.yml`),
   **both** version fields in `server.json`,
   `plugin/release.json` (pinned to pyproject by
   `tests/test_plugin_packaging.py`; the release the hooks report for the
   version handshake. `plugin/.claude-plugin/plugin.json` carries no
   version: Claude Code keys its cache by it, and the marketplace ships the
   plugin from every master commit), and
   `docs/atlas/atlas.json` `meta` (pinned by `tests/test_atlas_currency.py`;
   re-verify the map's claims, don't just renumber it — update
   `meta.verified` to the date you actually checked). `server.json` (both
   fields) and the `ops/docker-compose.yml` daemon image tag are also pinned
   locally by
   `tests/test_plugin_packaging.py::test_server_json_versions_match_pyproject`
   (issue #185) — run it as part of the cut, before tagging, so a missed
   field fails in the suite rather than mid-CI. Nothing pins the packaged
   compose copy's tag to the version: check it by hand.

   **The same PR also bumps the native crates** (`evals/rust_port/README.md`,
   the version-only release paragraph; #751 did it for 0.18.1): the
   `version` in `rust/shim/Cargo.toml` and in `rust/daemon/Cargo.toml`
   (the Rust daemon's `/health` version), the `pseudolife-stdio` and
   `pseudolife-daemon` entries in `rust/Cargo.lock`, `PACKAGE_VERSION` in
   `rust/shim/src/lifecycle.rs`, and the version literals in
   `rust/shim/tests/auth_lifecycle_http.rs`
   Without the shim's, `evals/rust_port/test_phase1_inventory.py`
   (`test_candidate_runtime_identity_matches_the_selected_python_pin`)
   fails, as it did at the first 0.18.2 cut; without the daemon's,
   `tests/test_plugin_packaging.py::test_the_native_crates_report_the_release_version`
   fails.

   **Keep the cut version-only.** PR parity tests the release PR against
   its own head only when
   `evals.rust_port.oracle_selection.release_cut(base, head)` accepts it:
   `pyproject.toml` changes only `project.version`, and every other changed
   path is a recognised document (`DOCUMENTS`, or a `.md` under `docs/`,
   `changelog.d/` or `rust/`) or an allowlisted version file
   (`VERSION_FILES`). Anything else, such as `plugin/README.md`, makes it
   return None, and parity then compares the new native shim against the old
   Python release. Put such doc fixes in a separate PR, and quote the
   `release_cut(<base>, <head>)` result in the release PR. Land it as a
   batch of one (`ops/integrate.py build` batches a cut alone), because its
   own PR runs only the fast CI lanes and a cut batched with code would
   fail `release_cut`.
   Tag `vN.N.N` at the exact commit the artifacts build from.
2. **Build + inspect before upload**: `python -m build`, `twine check dist/*`,
   then open the wheel — Console build present: every file
   `git ls-files pseudolife_memory/web/static` lists is in the wheel (11 at
   0.17.0; a fixed count rots with every Console rebuild), no stray
   top-level dirs, the `mcp-name` marker in METADATA,
   no identifiers (grep the METADATA for the guard list).
3. **PyPI**: publishing the GitHub release triggers
   `.github/workflows/release.yml` (Trusted Publishing — OIDC, no token):
   the `build` job guards tag == pyproject version **and** both `server.json`
   version fields == tag (issue #185 moved the server.json guard here, from
   the `registry` job below, so a mismatch fails before anything builds or
   uploads — it used to run after the irreversible PyPI publish and burn a
   release number), then builds, twine-checks, and waits for the user's
   one-click approval on the `pypi` environment. Manual `twine upload
   dist/*` remains the fallback. PyPI never accepts a same-version
   re-upload — metadata-only fixes are a `.postN`.
4. **MCP registry — automated.** The `registry` job in `release.yml` runs
   after the PyPI job and authenticates with `mcp-publisher login
   github-oidc` (same trust model as Trusted Publishing: a short-lived token
   minted per run, nothing stored, nothing to expire between cuts). It waits
   for PyPI to actually serve the new version, checks the marker survived
   into the published description, publishes, then confirms the registry
   serves it as latest — the `server.json` version check itself already
   happened in `build`, above. Publishing the GitHub release is now the
   single action that lands all four surfaces.
   *Manual fallback* (`mcp-publisher login github` then `publish`, from the
   repo root) if the job is ever broken — note the binary is often not on
   `PATH`; resolve it with `Get-Command mcp-publisher` rather than assuming.
   Either way: the README marker must read exactly
   `mcp-name: io.github.Pseudogiant-xr/pseudolife-mcp` — the namespace is
   matched **case-sensitively** against the GitHub username (capital P), and
   validation reads the **latest** PyPI release's description. The registry
   `description` field caps at 100 chars. Verify:
   `curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=pseudolife"`.
5. **GHCR images — automated.** The `images` job in `release.yml` (also
   downstream of the human-approved `publish` gate) builds and pushes
   `ghcr.io/pseudogiant-xr/pseudolife-daemon:{<version>,latest}` and
   `ghcr.io/pseudogiant-xr/pseudolife-pg:{18,18-<version>}` with the
   repo-scoped `GITHUB_TOKEN` — no stored secret. The compose overlay
   `ops/docker-compose.ghcr.yml` is the consumer (pull-not-build installs);
   the base compose file and `ops/update.ps1`'s local-build deploy flow are
   untouched. One-time after the FIRST release that runs the job: flip both
   packages to public in their GHCR package settings, or pulls fail with
   "denied". Verify:
   `docker manifest inspect ghcr.io/pseudogiant-xr/pseudolife-daemon:<version>`.

   **Opt-in Rust artifacts.** Before the release, the maintainer chooses
   whether to enable `PSEUDOLIFE_PUBLISH_RUST_DAEMON=true` (the separate
   `pseudolife-daemon-rust:<version>` image) and
   `PSEUDOLIFE_PUBLISH_RUST_ASSETS=true` (the five native CLI archives,
   `SHA256SUMS`, and `pseudolife-compose-<version>.tar.gz`). Both are
   repository variables, disabled when unset, and all publication remains
   downstream of the human-approved `publish` job. The Rust image gets
   only the explicit version tag, never `latest`, until the default switch
   is separately approved. Neither variable switches a host's engine:
   with the image published, release-mode `pseudolife-mcp update --engine
   rust` can select it for that release (and `--engine python` switches
   back); without it, that command stops at its `docker pull` with nothing
   changed. Verify a published image with
   `docker manifest inspect ghcr.io/pseudogiant-xr/pseudolife-daemon-rust:<version>`
   and flip its GHCR package to public after the first release that runs
   the job, as for the Python image.

   Run the build-only `release.yml` workflow dispatch on the candidate
   first: all five target binaries must compile and execute their version
   command after unpacking, and the complete compose/checksum artifact must
   pass verification. Dispatch never invokes PyPI, GHCR, the registry or
   GitHub asset publication. After an enabled release, inspect the Rust
   image digest, download the six archives plus `SHA256SUMS`, and verify
   every checksum and the archive-root layout in
   [the asset contract](../../../docs/dev/release-assets.md). The Rust
   GHCR package needs public visibility after its first approved push.
   The build job checks native crate versions before PyPI publication.
   If native asset attachment stops after a partial upload, rerun the
   asset publication job with the same complete artifact. It requires the
   exact file set and checksums, downloads and verifies already published
   assets (including `SHA256SUMS`), and uploads only missing ones. It never
   replaces existing bytes; investigate a mismatch before publication.
   Missing opt-in assets are a failed native publication, not permission
   to enable a fallback or change the default engine; keep installation
   and update readiness as release prerequisites.
6. **Deploy, then run the client step once.** `pseudolife-mcp update
   --clients-only` starts the updater of the runtime it was launched from,
   which is the *previous* release's. From the first release that carries
   the client hand-off (`CLIENT_HANDOFF` in
   `pseudolife_memory/update_cli.py`) onwards, that updater installs the new
   runtime and then hands the plugin, Codex and tunnel steps to it, so one
   run applies this release's client-step changes; its output says "the
   client steps after the shim continue in runtime …". When the release
   being replaced predates the hand-off (the hand-off release itself is the
   last such case), run `--clients-only` a second time from the new runtime
   (2026-10-05: 0.17.0's plugin-marketplace HTTPS move did nothing on the
   first run). Verify the release's own client-side changes after the run
   that printed the hand-off line, or after the second run.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

View all in ai-agents →

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.