← Publish Guard
API
Tokens

Run Publish Guard from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page dates every credential against the as-of day, counts owners and phishing-resistant 2FA, lints the release workflow (triggers, default permissions, SHA pinning, caches, the build and publish split, the protected environment, OIDC versus static secrets), ranks the credential rung per registry, chooses what goes first and scores the five gates, then hands it all to the run as facts. A literal token pasted by mistake is masked before anything is sent. The run writes the publishing plan or the rollback runbook; the page then holds it to its own facts (pgreply.js).

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"publish-guard"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required key (task, team, pipeline, facts) is missing. The page always sends all five of task, team, pipeline, carry, facts; carry is an empty string for plan.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes, chosen by task, in the order the work runs - the target state, then what to do when a release goes bad:

A missing or unknown task is answered as plan. Both lanes send the same five fields. The facts are the page's checks, one row per line, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the plan's decisions into carry for the runbook lane.

The input

FieldRequiredWhat
taskyesplan or runbook. If it is missing or neither, do plan and set "task": "plan".
teamyesthe team, what ships and the interview: CI and runners, paying customers, an incident, procurement, a deadline, a standing process, the as-of date; "not given" means not given.
pipelineyesthe inventory as the page read it (each package's registry, path, credential, expiry, owners, 2FA and findings), the release workflow as read, the policy answers and private consumption.
carrysend it (empty for plan)runbook task only: the plan's decisions, when the plan task ran first; may be empty.
factsyesthe page's checks, one line each as area | id | status | evidence, the rungs, what goes first and the private rung. A line starting "must:" applies to your reply.
retry_notenooptional; if present, your previous reply was unusable - follow it.

The rules the model applies

From the source skill, @samber/sdk-package-publishing - the only standard the run applies:

House rules

The replies

plan

Write the publishing plan - the target state and how to get there, not a transcript of today. Choosing an environment name, a channel or a migration date is a design decision; say what changes.

  1. summary - one sentence, at most 40 words: the biggest exposure today and the first move.
  2. assumptions - at most 5 short strings: what the plan assumes that the input does not settle; [] when none.
  3. registries - one object per registry the facts rank: registry (its name as the facts write it), rung (the facts' rung: trusted, narrow-token or tag-ruleset), approval (true or false, as ranked), provenance (true or false, as ranked), namespace (one sentence: what to claim and the owners), channel (one sentence: the version and pre-release policy), preflight (one dry-run or lint command; where the registry has no dry run, a local build or verify command that publishes nothing).
  4. workflow - trigger (one sentence: the protected tag), environment (the protected environment's name), reviewers (one sentence: who approves, or "" when no approval gate is ranked), hardening (short strings: the publish-job changes, in the vocabulary of the team's own CI - the GitHub terms above are the pattern, not literal settings for another CI).
  5. revoke - every existing token the plan retires (replaced by trusted publishing once validated, or by a new narrow bot token), by secret-store name or description, never a value; [] when none.
  6. migration - ordered steps: step (one sentence), by (YYYY-MM-DD), evidence (one sentence: what the step leaves behind for an audit).
  7. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "plan",
  "summary": "one sentence",
  "assumptions": [ ],
  "registries": [ { "registry": "npm", "rung": "trusted", "approval": true, "provenance": false, "namespace": "one sentence", "channel": "one sentence", "preflight": "npm publish --dry-run" } ],
  "workflow": { "trigger": "one sentence", "environment": "release", "reviewers": "one sentence", "hardening": [ "SHA-pin every third-party action" ] },
  "revoke": [ "NPM_TOKEN repository secret" ],
  "migration": [ { "step": "one sentence", "by": "2026-11-08", "evidence": "one sentence" } ],
  "caveats": [ ]
}

The page holds it to its own facts (check() in pgreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL; no backticks; no percentage; no secret value; a summary of at most 40 words; every ranked registry once and nothing else; each with the ranked rung, approval gate and provenance, a namespace, a channel and a preflight that does not publish; the protected environment and the trigger named; reviewers exactly when an approval gate is ranked; tokens to revoke when trusted publishing replaces them; migration steps dated YYYY-MM-DD on or after the as-of date, in order, each with its evidence, the first on what goes first and no later than its credential runs out; at most five assumptions.

runbook

Write the rollback runbook and the private-consumption setup - commands prepared for the team to run, never run by you.

  1. summary - one sentence, at most 40 words.
  2. registries - one object per registry the facts rank: registry (its name as the facts write it), rehearse_on (the registry's rehearsal target from the rules, never the production package), steps - exactly five objects in this order, each kind once: fix-version, soft-remove, isolate-rotate, hard-delete, announce; each with action (one sentence, the exact command where one exists, with placeholders).
  3. private - rung (the facts' private rung: endpoint-block, client-mapping or none), mapping (short strings: the client mappings per ecosystem; [] for none), probe (one sentence: the canary that proves internal names resolve internally only; "" for none).
  4. gates - five objects, one per gate: gate (tokens, ci, binaries, rollback or internal), test (one sentence: the check that proves it, or why it does not apply).
  5. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "runbook",
  "summary": "one sentence",
  "registries": [ { "registry": "npm", "rehearse_on": "a scratch scoped package", "steps": [ { "kind": "fix-version", "action": "one sentence" } ] } ],
  "private": { "rung": "client-mapping", "mapping": [ "npm scope mapped to the internal registry" ], "probe": "one sentence" },
  "gates": [ { "gate": "tokens", "test": "one sentence" } ],
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; the same text rules; every ranked registry once, each with exactly the five steps in order (fix-version, soft-remove, isolate-rotate, hard-delete, announce) and an action for each; the registry's own soft-removal command where it has one; no dry-run flag on a removal step; no RubyGems yank as the soft step; hard deletion reserved for leaked secrets or malware; a rehearsal target; the ranked private rung with client mappings and a probe; the five gates once each with a test.

Worked examples

Three runs from the page's free samples (invented teams): the plan for a payments SDK team on npm, PyPI and Maven Central, the rollback runbook for the same team carrying that plan, and the plan for a CLI after a compromised release. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.

plan - A payments SDK team on three registries

The page read it as: a publish path is exposed (11 fail, 6 warn, 1 pass), 3 packages, 0 of 4 gates; rungs: npm trusted + approval; PyPI trusted + approval; Maven Central narrow-token + approval; first: a hard deadline: com.payvarro:payvarro-java's credential on Maven Central runs out 2026-11-10; failing checks: tokens, expiry, owners, triggers, permissions, pinned, cache, environment, ci, rollback, internal.

Input (pipeline is 1470 characters; the first 1396 are shown; facts is 3743 characters; the first 1397 are shown):

{
 "task": "plan",
 "team": "Team or product: Payvarro SDKs\nWhat ships and to whom: Client SDKs for a payments API in JavaScript, Python and Java; thousands of merchants depend on them.\nShips: Libraries only; CI: GitHub Actions on hosted runners\nPaying customers or many dependents: yes; a recent incident: no; enterprise buyers or procurement ask for provenance: no; a deadline under two weeks (an expiring key or a cutoff): yes; a standing release process: yes\nAs-of date: 2026-11-01",
 "pipeline": "Inventory (package | registry | path | credential | expiry | owners | 2FA | the page's findings):\n- @payvarro/node | npm | ci | token | no expiry | 2 owners | an authenticator app | a long-lived token where npm accepts OIDC from GitHub Actions; no expiry on record - npm and NuGet tokens now expire within 30-90 days, so locate it or treat it as compromised; owners use an authenticator app - not phishing-resistant\n- payvarro | PyPI | ci | token | 2027-03-01 | 1 owner | a security key or passkey | a long-lived token where PyPI accepts OIDC from GitHub Actions; only 1 owner - keep at least two\n- com.payvarro:payvarro-java | Maven Central | laptop | token | 2026-11-10 | 2 owners | SMS | published from a laptop; expires in 9 days; owners use SMS - not phishing-resistant\nRelease workflow as read: triggers push, workflow_run; top-level permissions unset\n- job publish: publishes to npm, PyPI; environment none; OIDC identity no; secrets NPM_TOKEN, PYPI_TOKEN; caches 1; builds yes; needs nothing; dry run no\nActions: actions/checkout@v4 (not SHA-pinned), actions/setup-node@v4 (not SHA-pinned), pypa/gh-action-pypi-publish@release/v1 (not SHA-pinned)\nPolicy: namespace claimed yes; tag-equals-manifest guard no; pre-release channel no; rollback runbook written no, rehearsed no; binaries signed with checksums no\nPrivate packages: consumed yes; the team controls the feed no; the endpoint\n\n[... 74 more characters cut from this display ...]",
 "carry": "",
 "facts": "The page's checks (11 fail, 6 warn, 1 pass, 1 n/a); gates passed 0 of 4:\n- Credentials and owners | tokens | fail | @payvarro/node: a long-lived token where npm accepts OIDC from GitHub Actions; payvarro: a long-lived token where PyPI accepts OIDC from GitHub Actions; job publish publishes with NPM_TOKEN, PYPI_TOKEN\n- Credentials and owners | expiry | fail | com.payvarro:payvarro-java: expires in 9 days\n- Credentials and owners | located | warn | @payvarro/node: no expiry on record - npm and NuGet tokens now expire within 30-90 days, so locate it or treat it as compromised\n- Credentials and owners | owners | fail | payvarro: only 1 owner - keep at least two\n- Credentials and owners | twofa | warn | @payvarro/node: owners use an authenticator app - not phishing-resistant; com.payvarro:payvarro-java: owners use SMS - not phishing-resistant\n- The publish workflow | triggers | fail | workflow_run runs with secrets on code a contributor controls\n- The publish workflow | permissions | fail | unset - every job inherits the repository default\n- The publish workflow | pinned | fail | tag-pinned: pypa/gh-action-pypi-publish@release/v1\n- The publish workflow | cache | fail | caches in publish - cache poisoning was the first Ultralytics vector\n- The publish workflow | environment | fail | no environment on publish\n- The publish workflow | split | warn | publish builds and needs\n\n[... 2346 more characters cut from this display ...]"
}

Reply:

{
 "task": "plan",
 "summary": "Maven Central releases leave a laptop on a token that expires 2026-11-10, so the first move is a scoped bot token in a protected CI job before that date.",
 "assumptions": [
  "The organization can create a bot account on Maven Central to own the replacement token.",
  "The GitHub organization can host a protected environment with required reviewers.",
  "Merchants can tolerate pre-releases moving off the default channel without notice beyond the changelog."
 ],
 "registries": [
  {
   "registry": "npm",
   "rung": "trusted",
   "approval": true,
   "provenance": false,
   "namespace": "Keep the @payvarro scope claimed and move both owners to a security key or passkey in place of the authenticator app.",
   "channel": "One immutable SemVer version per tag, with pre-releases published under the next dist-tag instead of latest.",
   "preflight": "npm publish --dry-run"
  },
  {
   "registry": "PyPI",
   "rung": "trusted",
   "approval": true,
   "provenance": false,
   "namespace": "Add a second owner with a security key or passkey to the payvarro project so it no longer rests on one person.",
   "channel": "One immutable SemVer version per tag, with pre-release suffixes so pip skips them unless asked.",
   "preflight": "twine check dist/*"
  },
  {
   "registry": "Maven Central",
   "rung": "narrow-token",
   "approval": true,
   "provenance": false,
   "namespace": "Keep the com.payvarro group verified and move both owners from SMS to a security key or passkey.",
   "channel": "One immutable release per tag, with snapshots and release candidates kept out of the release repository.",
   "preflight": "mvn -B -P release verify"
  }
 ],
 "workflow": {
  "trigger": "Publishing starts only from a v* tag protected by a ruleset that limits tag creation to release maintainers, replacing the branch push and workflow_run triggers.",
  "environment": "release",
  "reviewers": "A payvarro-release-approvers group, or at least two people other than whoever cut the tag, with self-review blocked in the release environment.",
  "hardening": [
   "Remove the workflow_run trigger and the branch push trigger",
   "Set top-level permissions to none and grant id-token write only to the publish job",
   "SHA-pin actions/checkout, actions/setup-node and pypa/gh-action-pypi-publish",
   "Drop the restored cache from the build and publish jobs",
   "Split into an unprivileged build job that uploads artifacts and a publish job in the release environment",
   "Add a CI guard that the tag equals the manifest version in package.json, pyproject.toml and pom.xml",
   "Add npm publish --dry-run, twine check and mvn verify preflight steps"
  ]
 },
 "revoke": [
  "NPM_TOKEN repository secret",
  "PYPI_TOKEN repository secret",
  "The laptop-held Maven Central token once the bot-owned replacement has published one release"
 ],
 "migration": [
  {
   "step": "Issue a bot-owned Maven Central token scoped to com.payvarro, store it as MAVEN_CENTRAL_TOKEN in the release environment, and publish the next Java release from CI.",
   "by": "2026-11-08",
   "evidence": "A Java release built and deployed by the tagged CI run, with the environment approval recorded."
  },
  {
   "step": "Revoke the laptop-held Maven Central token and move both Maven Central owners off SMS.",
   "by": "2026-11-10",
   "evidence": "The token list on Maven Central showing only the bot token, and the owners' 2FA settings."
  },
  {
   "step": "Rewrite the workflow on a protected tag with the build and publish split, pinned actions, no caches and the release environment with required reviewers.",
   "by": "2026-11-13",
   "evidence": "The merged workflow change and a passing dry-run run of every preflight step."
  },
  {
   "step": "Register the PyPI trusted publisher for the release environment, validate it with one real release, then revoke PYPI_TOKEN and add a second PyPI owner.",
   "by": "2026-11-20",
   "evidence": "A PyPI release published through OIDC and the project's empty API token list."
  },
  {
   "step": "Configure npm trusted publishing for @payvarro/node, validate it with one real release, set the package to refuse tokens, then revoke NPM_TOKEN.",
   "by": "2026-11-27",
   "evidence": "An npm release published through OIDC and a package setting that disallows token publishing."
  },
  {
   "step": "Write and rehearse the rollback runbook for npm, PyPI and Maven Central, and move pip consumers to uv explicit sources for internal names.",
   "by": "2026-12-11",
   "evidence": "The runbook with the rehearsal log and the committed client mapping files."
  }
 ],
 "caveats": [
  "The npm token has no expiry on record and nobody located it, so treat it as compromised until it is revoked.",
  "Maven Central offers no OIDC, so the bot token needs automatic rotation for as long as it exists."
 ]
}

What step 5 prints for it: npm trusted | PyPI trusted | Maven Central narrow-token | 2026-11-08 | 2026-11-10 | 2026-11-13 | 2026-11-20 | 2026-11-27 | 2026-12-11

The page's check: kept - nothing to flag

runbook - A payments SDK team on three registries

The input carries the saved plan's decisions (R.laneForm fills carry from the plan reply), as the page's handoff does.

Input (pipeline is 1470 characters; the first 1396 are shown; facts is 3534 characters; the first 1397 are shown):

{
 "task": "runbook",
 "team": "Team or product: Payvarro SDKs\nWhat ships and to whom: Client SDKs for a payments API in JavaScript, Python and Java; thousands of merchants depend on them.\nShips: Libraries only; CI: GitHub Actions on hosted runners\nPaying customers or many dependents: yes; a recent incident: no; enterprise buyers or procurement ask for provenance: no; a deadline under two weeks (an expiring key or a cutoff): yes; a standing release process: yes\nAs-of date: 2026-11-01",
 "pipeline": "Inventory (package | registry | path | credential | expiry | owners | 2FA | the page's findings):\n- @payvarro/node | npm | ci | token | no expiry | 2 owners | an authenticator app | a long-lived token where npm accepts OIDC from GitHub Actions; no expiry on record - npm and NuGet tokens now expire within 30-90 days, so locate it or treat it as compromised; owners use an authenticator app - not phishing-resistant\n- payvarro | PyPI | ci | token | 2027-03-01 | 1 owner | a security key or passkey | a long-lived token where PyPI accepts OIDC from GitHub Actions; only 1 owner - keep at least two\n- com.payvarro:payvarro-java | Maven Central | laptop | token | 2026-11-10 | 2 owners | SMS | published from a laptop; expires in 9 days; owners use SMS - not phishing-resistant\nRelease workflow as read: triggers push, workflow_run; top-level permissions unset\n- job publish: publishes to npm, PyPI; environment none; OIDC identity no; secrets NPM_TOKEN, PYPI_TOKEN; caches 1; builds yes; needs nothing; dry run no\nActions: actions/checkout@v4 (not SHA-pinned), actions/setup-node@v4 (not SHA-pinned), pypa/gh-action-pypi-publish@release/v1 (not SHA-pinned)\nPolicy: namespace claimed yes; tag-equals-manifest guard no; pre-release channel no; rollback runbook written no, rehearsed no; binaries signed with checksums no\nPrivate packages: consumed yes; the team controls the feed no; the endpoint\n\n[... 74 more characters cut from this display ...]",
 "carry": "Registries (rung, approval, provenance): npm trusted +approval; PyPI trusted +approval; Maven Central narrow-token +approval\nEnvironment: release; reviewers: A payvarro-release-approvers group, or at least two people other than whoever cut the tag, with self-review blocked in the release environment.\nTo revoke: NPM_TOKEN repository secret; PYPI_TOKEN repository secret; The laptop-held Maven Central token once the bot-owned replacement has published one release\nMigration: 2026-11-08 Issue a bot-owned Maven Central token scoped to com.payvarro, store it as MAVEN_CENTRAL_TOKEN in the release environment, and publish the next Java release from CI. / 2026-11-10 Revoke the laptop-held Maven Central token and move both Maven Central owners off SMS. / 2026-11-13 Rewrite the workflow on a protected tag with the build and publish split, pinned actions, no caches and the release environment with required reviewers. / 2026-11-20 Register the PyPI trusted publisher for the release environment, validate it with one real release, then revoke PYPI_TOKEN and add a second PyPI owner. / 2026-11-27 Configure npm trusted publishing for @payvarro/node, validate it with one real release, set the package to refuse tokens, then revoke NPM_TOKEN. / 2026-12-11 Write and rehearse the rollback runbook for npm, PyPI and Maven Central, and move pip consumers to uv explicit sources for internal names.",
 "facts": "The page's checks (11 fail, 6 warn, 1 pass, 1 n/a); gates passed 0 of 4:\n- Credentials and owners | tokens | fail | @payvarro/node: a long-lived token where npm accepts OIDC from GitHub Actions; payvarro: a long-lived token where PyPI accepts OIDC from GitHub Actions; job publish publishes with NPM_TOKEN, PYPI_TOKEN\n- Credentials and owners | expiry | fail | com.payvarro:payvarro-java: expires in 9 days\n- Credentials and owners | located | warn | @payvarro/node: no expiry on record - npm and NuGet tokens now expire within 30-90 days, so locate it or treat it as compromised\n- Credentials and owners | owners | fail | payvarro: only 1 owner - keep at least two\n- Credentials and owners | twofa | warn | @payvarro/node: owners use an authenticator app - not phishing-resistant; com.payvarro:payvarro-java: owners use SMS - not phishing-resistant\n- The publish workflow | triggers | fail | workflow_run runs with secrets on code a contributor controls\n- The publish workflow | permissions | fail | unset - every job inherits the repository default\n- The publish workflow | pinned | fail | tag-pinned: pypa/gh-action-pypi-publish@release/v1\n- The publish workflow | cache | fail | caches in publish - cache poisoning was the first Ultralytics vector\n- The publish workflow | environment | fail | no environment on publish\n- The publish workflow | split | warn | publish builds and needs\n\n[... 2137 more characters cut from this display ...]"
}

Reply:

{
 "task": "runbook",
 "summary": "Each of npm, PyPI and Maven Central gets a fix-first rollback rehearsed on a scratch package, while pip and npm clients map internal names explicitly because the feed is outside Payvarro control.",
 "registries": [
  {
   "registry": "npm",
   "rehearse_on": "a scratch package under the @payvarro scope that no merchant installs",
   "steps": [
    {
     "kind": "fix-version",
     "action": "Tag v<good+1> so the release job publishes the fix through trusted publishing after approval in the release environment."
    },
    {
     "kind": "soft-remove",
     "action": "If latest points at the bad version run npm dist-tag add @payvarro/node@<good> latest, then npm deprecate @payvarro/node@<bad> \"use <good>\"."
    },
    {
     "kind": "isolate-rotate",
     "action": "On suspected compromise disable the release workflow and cancel queued runs first, then revoke NPM_TOKEN if it still exists and every secret in the release environment."
    },
    {
     "kind": "hard-delete",
     "action": "Reserved for leaked secrets or malware: npm unpublish @payvarro/node@<bad> inside npm's unpublish window, or a request to npm support after it."
    },
    {
     "kind": "announce",
     "action": "Add a changelog entry naming the bad and good versions and publish a security advisory on the repository."
    }
   ]
  },
  {
   "registry": "PyPI",
   "rehearse_on": "a scratch project on TestPyPI",
   "steps": [
    {
     "kind": "fix-version",
     "action": "Tag v<good+1> so the release job builds and publishes the fixed payvarro wheel and sdist through trusted publishing."
    },
    {
     "kind": "soft-remove",
     "action": "Yank payvarro <bad> in the project's release settings on PyPI with a reason naming <good>; twine cannot yank, and the registry API is the only scripted route."
    },
    {
     "kind": "isolate-rotate",
     "action": "On suspected compromise disable the release workflow first, then revoke PYPI_TOKEN if it remains and remove and re-add the trusted publisher."
    },
    {
     "kind": "hard-delete",
     "action": "Reserved for leaked secrets or malware: delete the release files in the PyPI project settings, knowing the version number can never be reused."
    },
    {
     "kind": "announce",
     "action": "Add a changelog entry and a repository security advisory listing payvarro <bad> and the fixed version."
    }
   ]
  },
  {
   "registry": "Maven Central",
   "rehearse_on": "a scratch artifact under a test groupId in a staging deployment that is dropped, never released",
   "steps": [
    {
     "kind": "fix-version",
     "action": "Tag v<good+1> so the release job runs mvn -B -P release deploy with MAVEN_CENTRAL_TOKEN from the release environment."
    },
    {
     "kind": "soft-remove",
     "action": "Maven Central has no soft removal, so the fix release is the removal: point the README and the BOM at <good+1>."
    },
    {
     "kind": "isolate-rotate",
     "action": "On suspected compromise disable the release workflow first, then revoke MAVEN_CENTRAL_TOKEN and any laptop-held token, rotate the GPG signing key and issue a new bot token."
    },
    {
     "kind": "hard-delete",
     "action": "Reserved for leaked secrets or malware: ask Central support to remove com.payvarro:payvarro-java:<bad>, since owners cannot delete a released artifact themselves."
    },
    {
     "kind": "announce",
     "action": "Add a changelog entry and a repository security advisory naming the bad and fixed coordinates."
    }
   ]
  }
 ],
 "private": {
  "rung": "client-mapping",
  "mapping": [
   "npm: .npmrc maps @<internal-scope> to the internal registry with a read-only CI token",
   "Python: uv or Poetry explicit sources pin every internal package to the internal index, replacing pip extra-index-url",
   "Maven: settings.xml and the pom declare the internal repository only for internal groupIds"
  ],
  "probe": "Publish a canary internal package name to a public test index at a higher version and confirm that a clean CI install still resolves it from the internal source only."
 },
 "gates": [
  {
   "gate": "tokens",
   "test": "The repository and release environment hold no NPM_TOKEN or PYPI_TOKEN, and MAVEN_CENTRAL_TOKEN is bot-owned, scoped to com.payvarro and expiring."
  },
  {
   "gate": "ci",
   "test": "Every npm, PyPI and Maven Central version in the last release maps to a tagged CI run approved in the release environment, with none from a laptop."
  },
  {
   "gate": "binaries",
   "test": "Not applicable: Payvarro ships libraries only and no binaries."
  },
  {
   "gate": "rollback",
   "test": "The rehearsal log shows each registry's five steps run against its scratch target with dates."
  },
  {
   "gate": "internal",
   "test": "The canary probe resolves every internal name from the internal source in npm, Python and Maven clients."
  }
 ],
 "caveats": [
  "Until the npm token is located and revoked, treat it as compromised in any isolate-rotate run.",
  "The written runbook is due 2026-12-11; until then these steps are untested."
 ]
}

What step 5 prints for it: npm: 5 steps | PyPI: 5 steps | Maven Central: 5 steps | tokens | ci | binaries | rollback | internal

The page's check: kept - nothing to flag

plan - A CLI after a compromised release

The page read it as: a publish path is exposed (5 fail, 2 warn, 11 pass), 3 packages, 1 of 4 gates; rungs: Go modules tag-ruleset; crates.io trusted + approval; Docker Hub trusted + approval; first: an incident runs the rollback runbook first; failing checks: tokens, owners, environment, rollback, binaries.

Input (facts is 2917 characters; the first 1398 are shown):

{
 "task": "plan",
 "team": "Team or product: Ostrewick CLI\nWhat ships and to whom: A developer CLI shipped as a Go module with binaries, a Rust crate for its parser and a Docker image; a compromised release went out last month.\nShips: Libraries, binaries and images; CI: GitHub Actions on hosted runners\nPaying customers or many dependents: no; a recent incident: yes; enterprise buyers or procurement ask for provenance: no; a deadline under two weeks (an expiring key or a cutoff): no; a standing release process: yes\nAs-of date: 2026-11-01",
 "pipeline": "Inventory (package | registry | path | credential | expiry | owners | 2FA | the page's findings):\n- github.com/ostrewick/ostre | Go modules | ci | none | no expiry | 2 owners | a security key or passkey\n- ostre-parser | crates.io | ci | oidc | no expiry | 1 owner | an authenticator app | only 1 owner - keep at least two; owners use an authenticator app - not phishing-resistant\n- ostrewick/ostre | Docker Hub | ci | token | 2026-12-20 | 2 owners | a security key or passkey | a long-lived token where Docker Hub accepts OIDC from GitHub Actions\nRelease workflow as read: triggers push; top-level permissions none or read\n- job build: publishes to Go modules; environment none; OIDC identity yes; secrets none; caches 0; builds yes; needs nothing; dry run no\n- job publish-crate: publishes to crates.io; environment release; OIDC identity yes; secrets none; caches 0; builds no; needs build; dry run yes\nActions: actions/checkout@<sha>, goreleaser/goreleaser-action@<sha>, actions/checkout@<sha>, rust-lang/crates-io-auth-action@<sha>\nPolicy: namespace claimed yes; tag-equals-manifest guard yes; pre-release channel yes; rollback runbook written yes, rehearsed no; binaries signed with checksums no\nPrivate packages: consumed no; the team controls the feed no; the endpoint blocks internal names upstream no; pip users no; release-age cooldown no",
 "carry": "",
 "facts": "The page's checks (5 fail, 2 warn, 11 pass, 1 n/a); gates passed 1 of 4:\n- Credentials and owners | tokens | fail | ostrewick/ostre: a long-lived token where Docker Hub accepts OIDC from GitHub Actions\n- Credentials and owners | expiry | pass | none within 30 days\n- Credentials and owners | located | pass | all named\n- Credentials and owners | owners | fail | ostre-parser: only 1 owner - keep at least two\n- Credentials and owners | twofa | warn | ostre-parser: owners use an authenticator app - not phishing-resistant\n- The publish workflow | triggers | pass | push\n- The publish workflow | permissions | pass | none or read only\n- The publish workflow | identity | pass | publish job only\n- The publish workflow | pinned | pass | 4 actions pinned\n- The publish workflow | cache | pass | none\n- The publish workflow | environment | fail | no environment on build\n- The publish workflow | split | warn | build builds and needs no build job\n- The publish workflow | preflight | pass | present\n- Versions, namespace and rollback | ci | pass | CI only\n- Versions, namespace and rollback | namespace | pass | claimed\n- Versions, namespace and rollback | versions | pass | tag guard, pre-release channel\n- Versions, namespace and rollback | rollback | fail | written, never rehearsed\n- Private consumption and binaries | binaries | fail | unsigned or without checksums\n- Private consumption and binaries |\n\n[... 1519 more characters cut from this display ...]"
}

Reply:

{
 "task": "plan",
 "summary": "After last month's compromised release the rollback runbook has never been rehearsed, so the first move is rehearsing it for Go modules, crates.io and Docker Hub before changing credentials.",
 "assumptions": [
  "The Docker Hub organization for ostrewick is on a paid or open-source program plan, which Docker Hub requires for OIDC.",
  "A second crates.io owner with a security key or passkey is available.",
  "GoReleaser in the build job can emit checksums and a signature or attestation for each binary."
 ],
 "registries": [
  {
   "registry": "Go modules",
   "rung": "tag-ruleset",
   "approval": false,
   "provenance": false,
   "namespace": "Keep github.com/ostrewick/ostre under the two owners with security keys, and limit who may create v* tags through a ruleset.",
   "channel": "One immutable SemVer tag per release, with pre-releases tagged -rc so go get without a version skips them.",
   "preflight": "goreleaser release --snapshot --clean"
  },
  {
   "registry": "crates.io",
   "rung": "trusted",
   "approval": true,
   "provenance": false,
   "namespace": "Add a second owner to ostre-parser and move both owners from an authenticator app to a security key or passkey.",
   "channel": "One immutable SemVer version per tag, with pre-release suffixes so Cargo never selects them by default.",
   "preflight": "cargo publish --dry-run"
  },
  {
   "registry": "Docker Hub",
   "rung": "trusted",
   "approval": true,
   "provenance": false,
   "namespace": "Keep ostrewick/ostre under its two owners with security keys and publish only from the OIDC identity bound to the release environment.",
   "channel": "One immutable version tag per release pointing at a single digest, with floating tags moved only for stable releases.",
   "preflight": "docker buildx build --platform linux/amd64,linux/arm64 ."
  }
 ],
 "workflow": {
  "trigger": "Publishing runs only on a v* tag guarded by a ruleset that limits tag creation to release maintainers, in place of the plain push trigger.",
  "environment": "release",
  "reviewers": "A ostrewick-release-approvers group, or at least two people besides whoever cut the tag, with self-review blocked in the release environment.",
  "hardening": [
   "Keep the build job unprivileged with no environment and no publish identity, uploading artifacts only",
   "Move the GoReleaser release step into a publish job in the release environment that needs build",
   "Add a Docker Hub publish job in the release environment using OIDC instead of the stored token",
   "Generate checksums and a signature or attestation for every binary in the release",
   "Keep every action SHA-pinned and caches out of build and publish"
  ]
 },
 "revoke": [
  "The long-lived Docker Hub access token for ostrewick/ostre stored in the repository secrets",
  "Any residual crates.io API tokens on the ostre-parser owner accounts"
 ],
 "migration": [
  {
   "step": "Rehearse the rollback runbook on scratch targets for Go modules, crates.io and Docker Hub, covering the fix release, soft removal and credential rotation.",
   "by": "2026-11-06",
   "evidence": "A dated rehearsal log with the commands run against each scratch target."
  },
  {
   "step": "Add a second crates.io owner and move both ostre-parser owners to a security key or passkey.",
   "by": "2026-11-10",
   "evidence": "The crate's owner list showing two owners and their 2FA method."
  },
  {
   "step": "Split the workflow so GoReleaser publishes from a job in the release environment with required reviewers, behind the tag ruleset.",
   "by": "2026-11-17",
   "evidence": "The merged workflow change and the ruleset audit showing who may create release tags."
  },
  {
   "step": "Configure Docker Hub OIDC for the release environment, validate it with one real image push, then revoke the stored Docker Hub token.",
   "by": "2026-11-24",
   "evidence": "An image pushed by the tagged CI run and an empty access token list for the publishing account."
  },
  {
   "step": "Ship checksums and a signature or attestation with every binary from the next release on.",
   "by": "2026-12-01",
   "evidence": "Release assets carrying a checksums file and a verifiable signature or attestation."
  }
 ],
 "caveats": [
  "The Docker Hub token expires 2026-12-20; if OIDC is not available on the plan, replace it with a scoped, expiring bot token before then.",
  "If the earlier compromise is still suspected, rotate every credential the build job could reach before rehearsing."
 ]
}

What step 5 prints for it: Go modules tag-ruleset | crates.io trusted | Docker Hub trusted | 2026-11-06 | 2026-11-10 | 2026-11-17 | 2026-11-24 | 2026-12-01

The page's check: kept - nothing to flag

Build the input yourself

Load the engine in node (global.window = global; require("./pgyaml.js"); require("./pgkit.js"); require("./pgreply.js")), then:

const K = PubKit, R = PubReply;
const f = K.form(Object.assign(K.blank(), { name: "acme", asof: "2026-11-01", inventory: "@acme/sdk | npm | ci | token | 2026-11-20 | 1 | totp\nacme-sdk | pypi | ci | oidc | | 2 | webauthn",
  workflow: "on:\n  push:\n    tags: [v*]\npermissions: {}\njobs:\n  publish:\n    runs-on: ubuntu-latest\n    steps:\n      - run: npm publish" }));
// f.inv (each package with its issues), f.wf (the workflow as read), f.rungs (the rung per registry), f.first, f.gate (the five gates)
// K.workflowYaml(f), K.rollbackMd(f), K.inventoryCsv(f) - the hardened workflow, the rollback sheet, the inventory
const b = K.buildInput(f, "plan");                           // or "runbook" (R.laneForm(form, "runbook", plan) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "plan");
// R.check(reply, b)                                         // {status: kept or flagged, notes}

Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://publish-guard.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"publish-guard"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "plan", "team": "Team or product: Payvarro SDKs\nWhat ships and to whom: ...", "pipeline": "Inventory (package | registry | path | credential | expiry | owners | 2FA | the page's findings):\n- @payvarro/node | npm | ci | token | no expiry ...", "carry": "", "facts": "The page's checks (12 fail, ...); gates passed 0 of 4:\n- Credentials and owners | tokens | fail | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses publish-guard:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A plan reply carries summary, assumptions, registries, workflow, revoke, migration and caveats; a runbook reply carries summary, registries (each with five rollback steps), private, gates and caveats. The code below prints the gist of either: each registry and its rung, or each gate and its test.

KEY="publish-guard:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "runbook" then (.gates[] | "\(.gate): \(.test)") 
       else (.registries[] | "\(.registry) \(.rung)") end' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: publish-guard:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json