diff --git a/app/components/Package/Header.vue b/app/components/Package/Header.vue
index e01e0dc03b..04fa3ee735 100644
--- a/app/components/Package/Header.vue
+++ b/app/components/Package/Header.vue
@@ -80,10 +80,10 @@ const { copied: copiedPkgVersion, copy: copyPkgVersion } = useClipboard({
copiedDuring: 2000,
})
-function hasProvenance(version: PackumentVersion | null): boolean {
- if (!version?.dist) return false
- return !!(version.dist as { attestations?: unknown }).attestations
-}
+const publishTrustStatus = computed(
+ () =>
+ (props.resolvedVersion && props.pkg?.versions[props.resolvedVersion]?.trustStatus) || undefined,
+)
const { announce } = useCommandPalette()
@@ -293,7 +293,23 @@ useShortcuts({
:tabindex="showScrollToTop ? 0 : -1"
/>
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+const props = withDefaults(
+ defineProps<{
+ compact?: boolean
+ linked?: boolean
+ }>(),
+ { linked: true },
+)
+
+const stagedPublishingDocs = 'https://docs.npmjs.com/staged-publishing/'
+
+
+
+
+
+
+ {{ $t('badges.staged_publish.label') }}
+
+
+
+
+
+ {{ $t('badges.staged_publish.label') }}
+
+
+
diff --git a/app/pages/package/[[org]]/[name]/versions.vue b/app/pages/package/[[org]]/[name]/versions.vue
index 8687ccec18..a5245b792a 100644
--- a/app/pages/package/[[org]]/[name]/versions.vue
+++ b/app/pages/package/[[org]]/[name]/versions.vue
@@ -329,6 +329,11 @@ const flatItems = computed(() => {
dir="ltr"
>v{{ latestTagRow!.version }}
+
(() => {
>
v{{ row.version }}
+
(() => {
>
v{{ item.version }}
+
({
version,
time: meta.time,
- hasProvenance: meta.provenance,
- hasTrustedPublisher: meta.trustedPublisher,
+ trustStatus: {
+ provenance: !!meta.provenance,
+ trustedPublisher: !!meta.trustedPublisher,
+ stagedPublish: !!meta.staged,
+ },
deprecated: meta.deprecated,
}))
.sort((a, b) => compare(b.version, a.version))
diff --git a/i18n/locales/en.json b/i18n/locales/en.json
index a09e22b59f..a1cd666408 100644
--- a/i18n/locales/en.json
+++ b/i18n/locales/en.json
@@ -1100,6 +1100,10 @@
"verified_title": "Verified provenance",
"verified_via": "Verified: published via {provider}"
},
+ "staged_publish": {
+ "label": "staged publish",
+ "title": "Published through staged publishing with 2FA approval"
+ },
"jsr": {
"title": "also available on JSR"
}
diff --git a/i18n/schema.json b/i18n/schema.json
index b7793e498c..035cf92ca9 100644
--- a/i18n/schema.json
+++ b/i18n/schema.json
@@ -3304,6 +3304,18 @@
},
"additionalProperties": false
},
+ "staged_publish": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
"jsr": {
"type": "object",
"properties": {
diff --git a/package.json b/package.json
index 68882eb4f5..a98dd5df8e 100644
--- a/package.json
+++ b/package.json
@@ -85,7 +85,7 @@
"algoliasearch": "5.55.1",
"defu": "6.1.7",
"diff": "^9.0.0",
- "fast-npm-meta": "2.1.0",
+ "fast-npm-meta": "2.2.0",
"focus-trap": "^8.0.0",
"gray-matter": "4.0.3",
"hls.js": "1.6.16",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b78ed99589..098225339d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -188,8 +188,8 @@ importers:
specifier: ^9.0.0
version: 9.0.0
fast-npm-meta:
- specifier: 2.1.0
- version: 2.1.0
+ specifier: 2.2.0
+ version: 2.2.0
focus-trap:
specifier: ^8.0.0
version: 8.2.2
@@ -6857,8 +6857,8 @@ packages:
resolution: {integrity: sha512-tWhw7z4jFuQgZB9tbQyUh5BY9nNd/wimM+fBLfmmJjakkJDNvbJKm0nQ5ruPKC0us1HGg7L6iBk1fxpSzcgSaA==}
hasBin: true
- fast-npm-meta@2.1.0:
- resolution: {integrity: sha512-Nfk1zTQvBmvh1XMxh9VkxMfRLHgv61YlIW80s4l/ZQVUbV4k4M5HjuQLlL8TooYD0AsSl19p4DU63wHZqZ0y9Q==}
+ fast-npm-meta@2.2.0:
+ resolution: {integrity: sha512-99jPl8JkCSCa4VlboNU1XuL98ijm74Pm9CGo6H4BoMVoVh1uhguQcvwLgXDT8Vkl2qj/UEQ0J9gD8beHjTFk1w==}
hasBin: true
fast-string-truncated-width@1.2.1:
@@ -17862,7 +17862,9 @@ snapshots:
fast-npm-meta@1.5.1: {}
- fast-npm-meta@2.1.0: {}
+ fast-npm-meta@2.2.0:
+ dependencies:
+ cac: 7.0.0
fast-string-truncated-width@1.2.1: {}
diff --git a/shared/types/npm-registry.ts b/shared/types/npm-registry.ts
index 97c4d99903..1187e612f5 100644
--- a/shared/types/npm-registry.ts
+++ b/shared/types/npm-registry.ts
@@ -20,7 +20,11 @@ export type { Manifest, ManifestVersion, PackageJSON } from '@npm/types'
type NpmTrustedPublisherEvidence = NpmSearchTrustedPublisher | NpmTrustedPublisher | true
export interface PackumentVersion extends PackumentVersionWithoutAttestations {
- _npmUser?: Contact & { trustedPublisher?: NpmTrustedPublisherEvidence }
+ _npmUser?: Contact & {
+ trustedPublisher?: NpmTrustedPublisherEvidence
+ /** Present when the version was released through staged publishing. */
+ approver?: Contact
+ }
dist: PackumentVersionWithoutAttestations['dist'] & { attestations?: NpmVersionAttestations }
}
diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts
index 0dc102ac11..30d5acd2e1 100644
--- a/test/nuxt/a11y.spec.ts
+++ b/test/nuxt/a11y.spec.ts
@@ -241,6 +241,7 @@ import {
PaginationControls,
ProgressBar,
ProvenanceBadge,
+ StagedPublishBadge,
Readme,
ReadmeTocDropdown,
SearchProviderToggle,
@@ -1006,6 +1007,22 @@ describe('component accessibility audits', () => {
})
})
+ describe('StagedPublishBadge', () => {
+ it('should have no accessibility violations with link', async () => {
+ const component = await mountSuspended(StagedPublishBadge)
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+
+ it('should have no accessibility violations without link', async () => {
+ const component = await mountSuspended(StagedPublishBadge, {
+ props: { linked: false },
+ })
+ const results = await runAxe(component)
+ expect(results.violations).toEqual([])
+ })
+ })
+
describe('PackageSkeleton', () => {
it('should have no accessibility violations', async () => {
const component = await mountSuspended(PackageSkeleton)
diff --git a/test/nuxt/components/Package/Versions.spec.ts b/test/nuxt/components/Package/Versions.spec.ts
index ff8220391b..04ecc5026e 100644
--- a/test/nuxt/components/Package/Versions.spec.ts
+++ b/test/nuxt/components/Package/Versions.spec.ts
@@ -18,14 +18,21 @@ function createVersion(
options: {
deprecated?: string
hasProvenance?: boolean
+ hasStagedPublish?: boolean
} = {},
): SlimVersion {
return {
version,
deprecated: options.deprecated,
tags: undefined,
- ...(options.hasProvenance
- ? { trustStatus: { provenance: true, trustedPublisher: false, stagedPublish: false } }
+ ...(options.hasProvenance || options.hasStagedPublish
+ ? {
+ trustStatus: {
+ provenance: !!options.hasProvenance,
+ trustedPublisher: false,
+ stagedPublish: !!options.hasStagedPublish,
+ },
+ }
: {}),
} as SlimVersion
}
@@ -389,6 +396,32 @@ describe('PackageVersions', () => {
const provenanceBadge = component.findComponent({ name: 'ProvenanceBadge' })
expect(provenanceBadge.exists()).toBe(false)
})
+
+ it('shows staged publishing when a version was approved from staging', async () => {
+ const component = await mountSuspended(PackageVersions, {
+ props: {
+ packageName: 'test-package',
+ versions: {
+ '1.0.0': createVersion('1.0.0', {
+ hasProvenance: true,
+ hasStagedPublish: true,
+ }),
+ },
+ distTags: { latest: '1.0.0' },
+ time: { '1.0.0': '2026-08-03T12:00:00.000Z' },
+ },
+ })
+
+ const badge = component.findComponent({ name: 'StagedPublishBadge' })
+ expect(badge.exists()).toBe(true)
+ expect(badge.get('a').attributes()).toMatchObject({
+ href: 'https://docs.npmjs.com/staged-publishing/',
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ title: 'Published through staged publishing with 2FA approval',
+ })
+ expect(component.findComponent({ name: 'ProvenanceBadge' }).exists()).toBe(true)
+ })
})
describe('datetime display', () => {
diff --git a/test/nuxt/composables/use-package-transform.spec.ts b/test/nuxt/composables/use-package-transform.spec.ts
index a97e1bc0f8..ba13c3dd5c 100644
--- a/test/nuxt/composables/use-package-transform.spec.ts
+++ b/test/nuxt/composables/use-package-transform.spec.ts
@@ -50,6 +50,16 @@ function createTrustedPublisherWithAttestationsVersion(version: string) {
}
}
+function createStagedPublishVersion(version: string) {
+ return {
+ ...createVersion(version, true),
+ _npmUser: {
+ name: 'publisher',
+ approver: { name: 'approver' },
+ },
+ }
+}
+
function createPackument(
versions: Packument['versions'],
time: Packument['time'],
@@ -80,6 +90,24 @@ function toVersionInfos(packument: ReturnType): Packa
}
describe('transformPackument', () => {
+ it('detects versions released through staged publishing', () => {
+ const packument = createPackument(
+ { '1.0.0': createStagedPublishVersion('1.0.0') },
+ {
+ 'created': '2026-08-03T00:00:00.000Z',
+ 'modified': '2026-08-03T00:00:00.000Z',
+ '1.0.0': '2026-08-03T00:00:00.000Z',
+ },
+ '1.0.0',
+ )
+
+ expect(transformPackument(packument, '1.0.0').versions['1.0.0']?.trustStatus).toEqual({
+ provenance: true,
+ trustedPublisher: false,
+ stagedPublish: true,
+ })
+ })
+
it('includes requested old version and preserves provenance on it', () => {
const packument = createPackument(
{