Skip to content

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:

SettingValue
Organizationmidnightntwrk
Repositorymidnight-verifiable-credentials
Workflow filenamepublish.yml
Environmentnpm-release
Allowed actionnpm 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:

  1. After a stable release, advance the root and every publishable package manifest to the next approved stable base before creating its first RC.
  2. Confirm the release PR is merged to the intended branch and CI is green.
  3. Confirm workspace-catalog.mjs --publishable-paths lists only approved reusable packages.
  4. Confirm the package changelog, support policy, and version are current.
  5. Confirm all packages have the exact npm trusted-publisher configuration above and the npm-release GitHub environment is protected.
  6. 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:

ChannelVersionMeaning
latest0.2.0Stable consumer release
rc0.3.0-rc1Explicit 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:

text
channel: rc
rc_index: 2

The 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: develop or main
  • release: main only

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:

text
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:

bash
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 latest

For 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:

  1. Move or remove the rc tag so new consumers cannot select it.
  2. Deprecate the bad immutable version with a concise migration message.
  3. Fix the source and publish the next RC index.
  4. Record the affected version, workflow run, impact, and corrective action.

Example operator commands:

bash
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:

  1. Stop any pending promotion or dependent release.
  2. Deprecate the bad immutable version with a concrete impact and upgrade message.
  3. Fix the source and publish a new patch version; that successful release moves latest forward.
  4. If impact requires immediately removing the bad version from latest, an npm owner must use a separately authorized, audited maintenance operation to move latest back to the last known-good version while the patch is prepared.
  5. 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:

  1. Stop or reject pending publish jobs.
  2. Revoke the npm token or trusted-publisher binding.
  3. Remove affected moving tags without deleting evidence.
  4. Preserve workflow logs, uploaded tarballs, SBOMs, provenance, and npm metadata.
  5. Notify @midnightntwrk/mn-sre, the technical owner, and the security channel defined in SECURITY.md.
  6. Publish a corrected version only after the source commit and evidence have been independently verified.

Midnight Verifiable Credentials core