verify.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. /**
  2. * Verify a release family's version baseline, and — when publishing — that the
  3. * run comes from the family's tag and its members are publishable.
  4. *
  5. * Publication happens only from GitHub Actions, so the tag and publishability
  6. * checks are gates on the workflow, not advisory local warnings
  7. * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
  8. */
  9. import { parseArgs } from 'node:util'
  10. import { isEntry } from './process.ts'
  11. import { releaseFamily, type PublishPlan, type ReleaseFamily, type ReleaseMember } from './families.ts'
  12. /**
  13. * Print the publish order the release will follow, and the peer declarations it
  14. * leaves unordered.
  15. *
  16. * The order is the release's own plan: an interrupted publication leaves exactly
  17. * a prefix of it, so reading it is how anyone judges what a partial run left on
  18. * the registry, and printing it on every pull request is what makes a change to
  19. * the order reviewable rather than only observable during a publication.
  20. * @param family - the release family.
  21. * @param plan - the resolved order and its dropped edges.
  22. */
  23. function reportPublishOrder(family: ReleaseFamily, plan: PublishPlan): void {
  24. console.log(`release verify: publish order for family ${family.id}, ${String(plan.order.length)} member(s):`)
  25. const width = String(plan.order.length).length
  26. for (const [index, member] of plan.order.entries()) {
  27. console.log(` ${String(index + 1).padStart(width, ' ')} ${member.name}@${member.version}`)
  28. }
  29. if (plan.droppedPeerEdges.length === 0) return
  30. console.log(
  31. `release verify: ${String(plan.droppedPeerEdges.length)} peer declaration(s) publish unordered,`
  32. + ' because the peer cannot precede the package declaring it without contradicting a dependency edge'
  33. + ' or its own cycle. npm treats an unmet peer as a warning, so this orders nothing and blocks nothing:',
  34. )
  35. for (const edge of plan.droppedPeerEdges) console.log(` ${edge.consumer} -> ${edge.peer}`)
  36. }
  37. /**
  38. * Assert every member may be published: npm refuses a `private` package.
  39. * @param members - the family's members.
  40. */
  41. function verifyPublishable(members: readonly ReleaseMember[]): void {
  42. const priv = members.filter(member => member.manifest.private === true)
  43. if (priv.length > 0) {
  44. throw new Error(`publishing requires removing "private": true from:\n${priv.map(member => member.directory).join('\n')}`)
  45. }
  46. }
  47. /**
  48. * Assert the workflow runs from a tag this family publishes from, and that the
  49. * tag names a version the family actually carries.
  50. * @param family - the release family.
  51. * @param members - the family's members.
  52. * @param ref - the `GITHUB_REF` value.
  53. */
  54. function verifyTag(family: ReleaseFamily, members: readonly ReleaseMember[], ref: string): void {
  55. const prefix = 'refs/tags/'
  56. if (!ref.startsWith(prefix)) {
  57. throw new Error(`publishing release family ${family.id} requires running from a ${family.tagPrefix}* tag, got ${ref || '(no ref)'}`)
  58. }
  59. const tag = ref.slice(prefix.length)
  60. if (!tag.startsWith(family.tagPrefix)) {
  61. throw new Error(`tag ${tag} does not belong to release family ${family.id} (expected ${family.tagPrefix}*)`)
  62. }
  63. const expected = members.map(member => family.tagFor(member))
  64. if (!expected.includes(tag)) {
  65. throw new Error(`tag ${tag} names no version this family carries; its members would tag as:\n${[...new Set(expected)].join('\n')}`)
  66. }
  67. }
  68. /** Run the verification for the family named by `--family`. */
  69. function main(): void {
  70. const { values } = parseArgs({
  71. options: { family: { type: 'string' } },
  72. allowPositionals: false,
  73. })
  74. if (values.family === undefined) throw new Error('usage: verify.ts --family <dsh|vendor>')
  75. const family = releaseFamily(values.family)
  76. const members = family.members(process.cwd())
  77. family.verifyVersions(members)
  78. // Resolve the publish order here, before the build: an install-edge cycle
  79. // makes the order unrepresentable, and that has to surface at the first gate
  80. // rather than when pack is already writing tarballs.
  81. const plan = family.publishOrder(members)
  82. if (plan.order.length !== members.length) {
  83. throw new Error(
  84. `release family ${family.id}: publish order covers ${String(plan.order.length)} of ${String(members.length)} members`,
  85. )
  86. }
  87. reportPublishOrder(family, plan)
  88. const publishing = process.env.RELEASE_PUBLISH === 'true'
  89. if (publishing) {
  90. verifyPublishable(members)
  91. verifyTag(family, members, process.env.GITHUB_REF ?? '')
  92. }
  93. const versions = [...new Set(members.map(member => member.version))]
  94. const summary = versions.length === 1 ? versions[0] : `${String(versions.length)} versions`
  95. console.log(
  96. `release verify: family ${family.id}, ${String(members.length)} member(s), ${summary},`
  97. + ` publish order resolved, ${String(plan.droppedPeerEdges.length)} peer declaration(s) unordered`
  98. + (publishing ? ', publish gates passed' : ''),
  99. )
  100. }
  101. if (isEntry(import.meta.url)) main()