npmjs Publication Runbook
This runbook covers public releases of reusable packages from midnight-verifiable-credentials. Concrete credential families, prototypes, use cases, reporting packages, and integration infrastructure are never part of this release train.
Ownership
- Technical package owner:
@midnightntwrk/ex-identus - npm credentials and incident owner:
@midnightntwrk/mn-sre - Security disclosure and escalation: security policy
Authentication
The normal release workflow uses npm trusted publishing through GitHub OIDC. It does not receive a long-lived npm token. Each publish runs in the protected npm-release GitHub environment and requests id-token: write only for the publishing job.
An npm owner must configure a trusted publisher on all supported packages with these exact values:
| Setting | Value |
|---|---|
| Organization | midnightntwrk |
| Repository | midnight-verifiable-credentials |
| Workflow filename | publish.yml |
| Environment | npm-release |
| Allowed action | npm publish |
The GitHub environment should require the designated release reviewers and permit only the repository release branches. @midnightntwrk/mn-sre owns the npm trusted-publisher bindings and incident response. Never place a token in repository files, workflow inputs, command arguments, artifacts, or logs.
npm trusted publishing requires npm 11.5.1 or newer and Node.js 22.14.0 or newer. The workflow uses the repository's Node.js 24 baseline and rejects an older npm CLI before any release work begins.
npm OIDC authorizes publication but not separate dist-tag or access commands. The normal path therefore applies public access and the intended tag as options to npm publish and performs no separate authenticated mutation. By default, --tag rc applies rc and preserves latest. Only a stable release may move latest. The workflow snapshots every dist-tag, records absent latest and rc tags explicitly, verifies the selected tag independently, and fails if any non-selected tag changes. Registry metadata errors fail closed. A tokenless idempotent rerun is a no-op when the immutable version and requested tag already exist. Publication runs share one repository-wide concurrency group, so RC and stable jobs cannot mutate separate tags at the same time. If the version exists under the wrong tag, the workflow stops and reports that a separately authorized maintenance operation is required. See the npm trusted-publishing limitations.
Release gates
Before dispatch:
- After a stable release, advance the root and every publishable package manifest to the next approved stable base before creating its first RC.
- Confirm the release PR is merged to the intended branch and CI is green.
- Confirm
workspace-catalog.mjs --publishable-pathslists only approved reusable packages. - Confirm the package changelog, support policy, and version are current.
- Confirm all packages have the exact npm trusted-publisher configuration above and the
npm-releaseGitHub environment is protected. - Confirm the requested version does not already contain different bytes.
The workflow reruns ./run.sh --light, validates the packed release contents, runs local clean-consumer tests, generates SBOMs, and publishes with provenance. It uploads the tested tarballs and SPDX SBOMs as a 90-day GitHub Actions artifact. Before an RC build starts, it also fails closed unless the manifest base is strictly newer than the current npm latest version of every catalog package. A stable build permits an equal base for an idempotent rerun, but rejects any base older than npm latest. Individual catalog packages without a published version are allowed so a new package can join an existing release train. The guard rejects a catalog where every package appears absent, because that result cannot be distinguished safely from a registry or configuration failure. An existing package without a latest tag also blocks publication until its registry state is triaged explicitly.
Prerelease publication
Current release-preparation baseline:
| Channel | Version | Meaning |
|---|---|---|
latest | 0.2.0 | Stable consumer release |
rc | 0.3.0-rc1 | Explicit 0.3 evaluation release |
Update this table and all guarded install examples only after registry verification succeeds. Cutting a changelog section or merging a release PR does not make a version installable.
Set the root and supported package manifests to the version approved by the release PR, then dispatch Publish npmjs Packages from develop. For example:
channel: rc
rc_index: 2The current release graph publishes three packages under the rc dist-tag:
@midnight-ntwrk/credential-model@midnight-ntwrk/credential-compact@midnight-ntwrk/credential-did-midnight
The workflow preserves an existing latest tag and fails if npm changes it during a prerelease.
Source manifests retain the approved base version. The workflow applies the channel suffix only to its ephemeral release checkout, so the reviewed source stays ready for the next channel dispatch.
Branch rules are fail closed:
rc:developormainrelease:mainonly
Automatic publication on pushes is intentionally disabled.
Stable publication
Set the root and supported package manifests to the approved stable version, merge the release promotion to main, and dispatch Publish npmjs Packages from main with:
channel: release
rc_index: <empty>The release channel publishes the exact manifest version under latest; the publish command does not intentionally mutate rc. Stable publication from develop causes the publish job to be skipped before evidence or packages are produced; operators MUST treat that skipped job as a failed release attempt. The workflow verifies the selected latest tag, while the operator must compare rc with the pre-dispatch snapshot in the release-evidence artifact.
Verification
The workflow waits for bounded npmjs propagation, installs each exact package version into a fresh temporary project, rejects local locators, and runs the cataloged Node, TypeScript, browser, and applicable Compact checks.
Pass VERSION and TAG as positional arguments to the verifier below so the selected values cannot be shadowed by defaults. This stable example verifies the 0.2.0 release:
verify_release() (
set -euo pipefail
VERSION="${1:?usage: verify_release VERSION TAG}"
TAG="${2:?usage: verify_release VERSION TAG}"
NPM_REGISTRY=https://registry.npmjs.org/
verification_failed=0
if ! package_paths="$(node \
./tooling/scripts/workspace-catalog.mjs --publishable-paths)"; then
printf 'unable to read the publishable package catalog\n' >&2
exit 1
fi
if [[ -z "$package_paths" ]]; then
printf 'publishable package catalog is empty\n' >&2
exit 1
fi
while IFS= read -r package_path; do
if ! package="$(node -p \
"require('./${package_path}/package.json').name")"; then
printf 'unable to read package metadata for %s\n' "$package_path" >&2
verification_failed=1
continue
fi
if ! actual_version="$(npm view --registry "$NPM_REGISTRY" \
"${package}@${VERSION}" version)"; then
printf 'unable to read %s@%s\n' "${package}" "${VERSION}" >&2
verification_failed=1
elif [[ "${actual_version}" != "${VERSION}" ]]; then
printf 'expected %s version=%s, got %s\n' \
"${package}" "${VERSION}" "${actual_version}" >&2
verification_failed=1
fi
if ! actual_tag="$(npm view --registry "$NPM_REGISTRY" \
"${package}" "dist-tags.${TAG}")"; then
printf 'unable to read %s dist-tag %s\n' "${package}" "${TAG}" >&2
verification_failed=1
elif [[ "${actual_tag}" != "${VERSION}" ]]; then
printf 'expected %s %s=%s, got %s\n' \
"${package}" "${TAG}" "${VERSION}" "${actual_tag}" >&2
verification_failed=1
fi
if ! npm view --registry "$NPM_REGISTRY" "${package}" dist-tags --json; then
printf 'unable to read %s dist-tags\n' "${package}" >&2
verification_failed=1
fi
done <<< "$package_paths"
exit "$verification_failed"
)
verify_release 0.2.0 latestFor an RC, call the function with the suffixed version and rc, for example verify_release 0.3.0-rc1 rc. The workflow verifies the selected tag and fails if any other recorded tag is added, removed, or changed relative to the pre-dispatch snapshot. Inspecting release-state.json from the release-evidence artifact remains useful secondary evidence, but it is not the only guard for stable channels. Retain the workflow URL and artifact with the release record.
Retry and rollback
npm package versions are immutable. A rerun skips an existing exact version. It is a no-op when the requested dist-tag is already correct. A mismatched tag requires a separate, human-authorized npm maintenance operation because OIDC does not authorize npm dist-tag. Never unpublish a consumed release as a routine rollback.
A successful npm publish response means npm accepted the mutation, but the new version may remain temporarily absent from registry reads. If the workflow times out after publication, do not immediately rerun the publishing job. Preserve the run evidence and repeat the read-only verification commands above until the propagation window has been ruled out. Rerun only after confirming the exact version is absent; the workflow will otherwise take its idempotent verification path.
For a bad RC:
- Move or remove the
rctag so new consumers cannot select it. - Deprecate the bad immutable version with a concise migration message.
- Fix the source and publish the next RC index.
- Record the affected version, workflow run, impact, and corrective action.
Example operator commands:
rollback_rc() (
set -euo pipefail
VERSION="${1:?usage: rollback_rc CONFIRMED_BAD_RC}"
NPM_REGISTRY=https://registry.npmjs.org/
view_stderr="$(mktemp)"
trap 'rm -f "$view_stderr"' EXIT
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc[1-9][0-9]*$ ]]; then
printf 'refusing rollback: %s is not an RC version\n' "$VERSION" >&2
exit 1
fi
if ! package_paths="$(node \
./tooling/scripts/workspace-catalog.mjs --publishable-paths)"; then
printf 'unable to read the publishable package catalog\n' >&2
exit 1
fi
if [[ -z "$package_paths" ]]; then
printf 'publishable package catalog is empty\n' >&2
exit 1
fi
packages=()
while IFS= read -r package_path; do
if ! package="$(node -p \
"require('./${package_path}/package.json').name")"; then
printf 'unable to read package metadata for %s\n' "$package_path" >&2
exit 1
fi
packages+=("$package")
done <<< "$package_paths"
published_packages=()
for package in "${packages[@]}"; do
if ! current_rc="$(npm view --registry "$NPM_REGISTRY" \
"$package" dist-tags.rc 2>"$view_stderr")"; then
view_error="$(<"$view_stderr")"
if grep -Eq '(E404|404 Not Found)' \
<<< "$current_rc"$'\n'"$view_error"; then
current_rc=""
else
printf 'unable to read %s rc tag: %s\n' \
"$package" "$current_rc$view_error" >&2
exit 1
fi
elif [[ -s "$view_stderr" ]]; then
cat "$view_stderr" >&2
fi
if ! versions_json="$(npm view --registry "$NPM_REGISTRY" \
"$package" versions --json 2>"$view_stderr")"; then
view_error="$(<"$view_stderr")"
if grep -Eq '(E404|404 Not Found)' \
<<< "$versions_json"$'\n'"$view_error"; then
continue
fi
printf 'unable to read published versions for %s: %s\n' \
"$package" "$versions_json$view_error" >&2
exit 1
elif [[ -s "$view_stderr" ]]; then
cat "$view_stderr" >&2
fi
if node -e '
const value = JSON.parse(process.argv[1]);
const versions = Array.isArray(value) ? value : [value];
process.exit(versions.includes(process.argv[2]) ? 0 : 1);
' "$versions_json" "$VERSION"; then
published_packages+=("$package")
elif [[ "$current_rc" == "$VERSION" ]]; then
printf 'refusing rollback: %s rc tag names an unpublished version\n' \
"$package" >&2
exit 1
fi
done
if [[ "${#published_packages[@]}" -eq 0 ]]; then
printf 'nothing to roll back: %s is not published\n' "$VERSION"
exit 0
fi
for package in "${published_packages[@]}"; do
if ! current_rc="$(npm view --registry "$NPM_REGISTRY" \
"$package" dist-tags.rc 2>"$view_stderr")"; then
view_error="$(<"$view_stderr")"
printf 'unable to re-read %s rc tag: %s\n' \
"$package" "$current_rc$view_error" >&2
exit 1
elif [[ -s "$view_stderr" ]]; then
cat "$view_stderr" >&2
fi
if [[ "$current_rc" == "$VERSION" ]]; then
npm dist-tag rm --registry "$NPM_REGISTRY" "${package}" rc
fi
npm deprecate --registry "$NPM_REGISTRY" \
"${package}@${VERSION}" "Use the replacement RC"
done
)The command is resumable after a partial failure: it accepts an already-absent rc tag, preserves a tag that already points to a different version, and rechecks every tag before each mutation. From the repository root, invoke it with the confirmed bad RC, for example rollback_rc 0.3.0-rc3.
Do not move latest during RC rollback.
For a bad stable release:
- Stop any pending promotion or dependent release.
- Deprecate the bad immutable version with a concrete impact and upgrade message.
- Fix the source and publish a new patch version; that successful release moves
latestforward. - If impact requires immediately removing the bad version from
latest, an npm owner must use a separately authorized, audited maintenance operation to movelatestback to the last known-good version while the patch is prepared. - Record both tag states, affected versions, workflow runs, impact, and corrective action. Do not unpublish the consumed stable version as a routine rollback.
Incident response
For suspected token, workflow, provenance, or tarball compromise:
- Stop or reject pending publish jobs.
- Revoke the npm token or trusted-publisher binding.
- Remove affected moving tags without deleting evidence.
- Preserve workflow logs, uploaded tarballs, SBOMs, provenance, and npm metadata.
- Notify
@midnightntwrk/mn-sre, the technical owner, and the security channel defined inSECURITY.md. - Publish a corrected version only after the source commit and evidence have been independently verified.