feat(release): add release family metadata, pack, verify, and publish
A release family owns its member discovery, version baseline, tag naming, and
packed-payload rule; the dsh family shares one version across packages/ and
apps/, while every vendor/ package keeps its own version line. Publish order is
topological over runtime dependencies so no package reaches the registry before
one it depends on.
pack packs the whole family into one directory and records the upload order;
publish decides per package against the registry, skipping a version whose
published tarball has the same integrity and failing when it differs, which is
what makes re-running publish over one artifact safe.
The vendored packages keep upstream's payload: their manifests export ./src/*,
so the harness rule that rejects sources and declaration maps would publish an
export map pointing at absent files.
2026-08-10 23:35:23 +08:00
|
|
|
/**
|
|
|
|
|
* Verify a release family's version baseline, and — when publishing — that the
|
|
|
|
|
* run comes from the family's tag and its members are publishable.
|
|
|
|
|
*
|
|
|
|
|
* Publication happens only from GitHub Actions, so the tag and publishability
|
|
|
|
|
* checks are gates on the workflow, not advisory local warnings
|
fix(release): close the review findings on the release sequences
The root manifest carries the dsh family version. bump writes it with the
members, because the workspace constraint requires them to match, and that
constraint now accepts a prerelease segment: without both, release:dsh 0.0.2
left the root behind and 0.0.1-rc.1 could satisfy neither check.
The Landlock workflow no longer passes --access public, which overrode the
restricted publishConfig this repository just adopted for those packages.
Vendored change detection reads build inputs when a package publishes build
output, and vendor/cordis publishes the src its export map already pointed at:
its lib/ is untracked, so a real source edit read as 'nothing changed' and the
next publish would fail on a version whose bytes moved. The next version also
takes the last published version as its baseline, so a re-sync that restores a
lower upstream version cannot recompute a version already on the registry, and
bump confirms the registry carries what the newest tag names.
Tag prefixes are constructed rather than recovered from a full tag, which a
hyphenated version defeated. Pack runs group per ref so concurrent pull requests
stop displacing each other, the publish job carries the global group, and the
unused id-token permission is gone.
Every release script sits behind an entry guard, which is what lets the pure
judgements carry tests: tag naming, publish order and cycle reporting, version
arithmetic, payload policy, and the change judgement.
The Agent Note moves to implemented and states what shipped: one probe command,
the registry confirmation that now exists, and byte reproducibility recorded as
assumed rather than measured.
2026-08-11 01:26:36 +08:00
|
|
|
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
feat(release): add release family metadata, pack, verify, and publish
A release family owns its member discovery, version baseline, tag naming, and
packed-payload rule; the dsh family shares one version across packages/ and
apps/, while every vendor/ package keeps its own version line. Publish order is
topological over runtime dependencies so no package reaches the registry before
one it depends on.
pack packs the whole family into one directory and records the upload order;
publish decides per package against the registry, skipping a version whose
published tarball has the same integrity and failing when it differs, which is
what makes re-running publish over one artifact safe.
The vendored packages keep upstream's payload: their manifests export ./src/*,
so the harness rule that rejects sources and declaration maps would publish an
export map pointing at absent files.
2026-08-10 23:35:23 +08:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { parseArgs } from 'node:util'
|
fix(release): close the review findings on the release sequences
The root manifest carries the dsh family version. bump writes it with the
members, because the workspace constraint requires them to match, and that
constraint now accepts a prerelease segment: without both, release:dsh 0.0.2
left the root behind and 0.0.1-rc.1 could satisfy neither check.
The Landlock workflow no longer passes --access public, which overrode the
restricted publishConfig this repository just adopted for those packages.
Vendored change detection reads build inputs when a package publishes build
output, and vendor/cordis publishes the src its export map already pointed at:
its lib/ is untracked, so a real source edit read as 'nothing changed' and the
next publish would fail on a version whose bytes moved. The next version also
takes the last published version as its baseline, so a re-sync that restores a
lower upstream version cannot recompute a version already on the registry, and
bump confirms the registry carries what the newest tag names.
Tag prefixes are constructed rather than recovered from a full tag, which a
hyphenated version defeated. Pack runs group per ref so concurrent pull requests
stop displacing each other, the publish job carries the global group, and the
unused id-token permission is gone.
Every release script sits behind an entry guard, which is what lets the pure
judgements carry tests: tag naming, publish order and cycle reporting, version
arithmetic, payload policy, and the change judgement.
The Agent Note moves to implemented and states what shipped: one probe command,
the registry confirmation that now exists, and byte reproducibility recorded as
assumed rather than measured.
2026-08-11 01:26:36 +08:00
|
|
|
import { isEntry } from './process.ts'
|
fix(release): print the publish order and the peer edges it drops
The verify step resolved the publish order and said only that it had:
the order a release actually follows, and the ordering it could not
honour, stayed invisible until a publication was already running.
publishOrder now returns that order together with the peer edges it
dropped, verify prints both, and pack reads the order off the plan. The
dropped edges are part of the result rather than a detail of forming it:
the dsh family drops one (dsh-api-remotes -> dsh-api-gateway) and the
vendored family drops two (cordis-plugin-include and
cordis-plugin-loader, which cordis declares as peers in return), and
only whoever reads the log can judge whether a newly dropped edge is
expected.
Because pack runs on every pull request and master push, a change to the
order is now reviewable there rather than observable only at publish
time.
The order is also checked against the edges it exists to honour. A cycle
mixing peer and dependency declarations can put a dependency on the
traversal stack, where it is skipped like a peer edge, emitting a
consumer before something it installs; no later step can detect that,
and it would surface as an unresolvable install for a consumer of the
published packages. No family has that shape today, and the new test
pins the three-package case that would.
2026-08-14 13:38:35 +08:00
|
|
|
import { releaseFamily, type PublishPlan, type ReleaseFamily, type ReleaseMember } from './families.ts'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Print the publish order the release will follow, and the peer declarations it
|
|
|
|
|
* leaves unordered.
|
|
|
|
|
*
|
|
|
|
|
* The order is the release's own plan: an interrupted publication leaves exactly
|
|
|
|
|
* a prefix of it, so reading it is how anyone judges what a partial run left on
|
|
|
|
|
* the registry, and printing it on every pull request is what makes a change to
|
|
|
|
|
* the order reviewable rather than only observable during a publication.
|
|
|
|
|
* @param family - the release family.
|
|
|
|
|
* @param plan - the resolved order and its dropped edges.
|
|
|
|
|
*/
|
|
|
|
|
function reportPublishOrder(family: ReleaseFamily, plan: PublishPlan): void {
|
|
|
|
|
console.log(`release verify: publish order for family ${family.id}, ${String(plan.order.length)} member(s):`)
|
|
|
|
|
const width = String(plan.order.length).length
|
|
|
|
|
for (const [index, member] of plan.order.entries()) {
|
|
|
|
|
console.log(` ${String(index + 1).padStart(width, ' ')} ${member.name}@${member.version}`)
|
|
|
|
|
}
|
|
|
|
|
if (plan.droppedPeerEdges.length === 0) return
|
|
|
|
|
console.log(
|
|
|
|
|
`release verify: ${String(plan.droppedPeerEdges.length)} peer declaration(s) publish unordered,`
|
|
|
|
|
+ ' because the peer cannot precede the package declaring it without contradicting a dependency edge'
|
|
|
|
|
+ ' or its own cycle. npm treats an unmet peer as a warning, so this orders nothing and blocks nothing:',
|
|
|
|
|
)
|
|
|
|
|
for (const edge of plan.droppedPeerEdges) console.log(` ${edge.consumer} -> ${edge.peer}`)
|
|
|
|
|
}
|
feat(release): add release family metadata, pack, verify, and publish
A release family owns its member discovery, version baseline, tag naming, and
packed-payload rule; the dsh family shares one version across packages/ and
apps/, while every vendor/ package keeps its own version line. Publish order is
topological over runtime dependencies so no package reaches the registry before
one it depends on.
pack packs the whole family into one directory and records the upload order;
publish decides per package against the registry, skipping a version whose
published tarball has the same integrity and failing when it differs, which is
what makes re-running publish over one artifact safe.
The vendored packages keep upstream's payload: their manifests export ./src/*,
so the harness rule that rejects sources and declaration maps would publish an
export map pointing at absent files.
2026-08-10 23:35:23 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Assert every member may be published: npm refuses a `private` package.
|
|
|
|
|
* @param members - the family's members.
|
|
|
|
|
*/
|
|
|
|
|
function verifyPublishable(members: readonly ReleaseMember[]): void {
|
|
|
|
|
const priv = members.filter(member => member.manifest.private === true)
|
|
|
|
|
if (priv.length > 0) {
|
|
|
|
|
throw new Error(`publishing requires removing "private": true from:\n${priv.map(member => member.directory).join('\n')}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Assert the workflow runs from a tag this family publishes from, and that the
|
|
|
|
|
* tag names a version the family actually carries.
|
|
|
|
|
* @param family - the release family.
|
|
|
|
|
* @param members - the family's members.
|
|
|
|
|
* @param ref - the `GITHUB_REF` value.
|
|
|
|
|
*/
|
|
|
|
|
function verifyTag(family: ReleaseFamily, members: readonly ReleaseMember[], ref: string): void {
|
|
|
|
|
const prefix = 'refs/tags/'
|
|
|
|
|
if (!ref.startsWith(prefix)) {
|
|
|
|
|
throw new Error(`publishing release family ${family.id} requires running from a ${family.tagPrefix}* tag, got ${ref || '(no ref)'}`)
|
|
|
|
|
}
|
|
|
|
|
const tag = ref.slice(prefix.length)
|
|
|
|
|
if (!tag.startsWith(family.tagPrefix)) {
|
|
|
|
|
throw new Error(`tag ${tag} does not belong to release family ${family.id} (expected ${family.tagPrefix}*)`)
|
|
|
|
|
}
|
|
|
|
|
const expected = members.map(member => family.tagFor(member))
|
|
|
|
|
if (!expected.includes(tag)) {
|
|
|
|
|
throw new Error(`tag ${tag} names no version this family carries; its members would tag as:\n${[...new Set(expected)].join('\n')}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Run the verification for the family named by `--family`. */
|
|
|
|
|
function main(): void {
|
|
|
|
|
const { values } = parseArgs({
|
|
|
|
|
options: { family: { type: 'string' } },
|
|
|
|
|
allowPositionals: false,
|
|
|
|
|
})
|
|
|
|
|
if (values.family === undefined) throw new Error('usage: verify.ts --family <dsh|vendor>')
|
|
|
|
|
|
|
|
|
|
const family = releaseFamily(values.family)
|
|
|
|
|
const members = family.members(process.cwd())
|
|
|
|
|
family.verifyVersions(members)
|
2026-08-14 11:27:13 +08:00
|
|
|
// Resolve the publish order here, before the build: an install-edge cycle
|
|
|
|
|
// makes the order unrepresentable, and that has to surface at the first gate
|
|
|
|
|
// rather than when pack is already writing tarballs.
|
fix(release): print the publish order and the peer edges it drops
The verify step resolved the publish order and said only that it had:
the order a release actually follows, and the ordering it could not
honour, stayed invisible until a publication was already running.
publishOrder now returns that order together with the peer edges it
dropped, verify prints both, and pack reads the order off the plan. The
dropped edges are part of the result rather than a detail of forming it:
the dsh family drops one (dsh-api-remotes -> dsh-api-gateway) and the
vendored family drops two (cordis-plugin-include and
cordis-plugin-loader, which cordis declares as peers in return), and
only whoever reads the log can judge whether a newly dropped edge is
expected.
Because pack runs on every pull request and master push, a change to the
order is now reviewable there rather than observable only at publish
time.
The order is also checked against the edges it exists to honour. A cycle
mixing peer and dependency declarations can put a dependency on the
traversal stack, where it is skipped like a peer edge, emitting a
consumer before something it installs; no later step can detect that,
and it would surface as an unresolvable install for a consumer of the
published packages. No family has that shape today, and the new test
pins the three-package case that would.
2026-08-14 13:38:35 +08:00
|
|
|
const plan = family.publishOrder(members)
|
|
|
|
|
if (plan.order.length !== members.length) {
|
2026-08-14 11:27:13 +08:00
|
|
|
throw new Error(
|
fix(release): print the publish order and the peer edges it drops
The verify step resolved the publish order and said only that it had:
the order a release actually follows, and the ordering it could not
honour, stayed invisible until a publication was already running.
publishOrder now returns that order together with the peer edges it
dropped, verify prints both, and pack reads the order off the plan. The
dropped edges are part of the result rather than a detail of forming it:
the dsh family drops one (dsh-api-remotes -> dsh-api-gateway) and the
vendored family drops two (cordis-plugin-include and
cordis-plugin-loader, which cordis declares as peers in return), and
only whoever reads the log can judge whether a newly dropped edge is
expected.
Because pack runs on every pull request and master push, a change to the
order is now reviewable there rather than observable only at publish
time.
The order is also checked against the edges it exists to honour. A cycle
mixing peer and dependency declarations can put a dependency on the
traversal stack, where it is skipped like a peer edge, emitting a
consumer before something it installs; no later step can detect that,
and it would surface as an unresolvable install for a consumer of the
published packages. No family has that shape today, and the new test
pins the three-package case that would.
2026-08-14 13:38:35 +08:00
|
|
|
`release family ${family.id}: publish order covers ${String(plan.order.length)} of ${String(members.length)} members`,
|
2026-08-14 11:27:13 +08:00
|
|
|
)
|
|
|
|
|
}
|
fix(release): print the publish order and the peer edges it drops
The verify step resolved the publish order and said only that it had:
the order a release actually follows, and the ordering it could not
honour, stayed invisible until a publication was already running.
publishOrder now returns that order together with the peer edges it
dropped, verify prints both, and pack reads the order off the plan. The
dropped edges are part of the result rather than a detail of forming it:
the dsh family drops one (dsh-api-remotes -> dsh-api-gateway) and the
vendored family drops two (cordis-plugin-include and
cordis-plugin-loader, which cordis declares as peers in return), and
only whoever reads the log can judge whether a newly dropped edge is
expected.
Because pack runs on every pull request and master push, a change to the
order is now reviewable there rather than observable only at publish
time.
The order is also checked against the edges it exists to honour. A cycle
mixing peer and dependency declarations can put a dependency on the
traversal stack, where it is skipped like a peer edge, emitting a
consumer before something it installs; no later step can detect that,
and it would surface as an unresolvable install for a consumer of the
published packages. No family has that shape today, and the new test
pins the three-package case that would.
2026-08-14 13:38:35 +08:00
|
|
|
reportPublishOrder(family, plan)
|
feat(release): add release family metadata, pack, verify, and publish
A release family owns its member discovery, version baseline, tag naming, and
packed-payload rule; the dsh family shares one version across packages/ and
apps/, while every vendor/ package keeps its own version line. Publish order is
topological over runtime dependencies so no package reaches the registry before
one it depends on.
pack packs the whole family into one directory and records the upload order;
publish decides per package against the registry, skipping a version whose
published tarball has the same integrity and failing when it differs, which is
what makes re-running publish over one artifact safe.
The vendored packages keep upstream's payload: their manifests export ./src/*,
so the harness rule that rejects sources and declaration maps would publish an
export map pointing at absent files.
2026-08-10 23:35:23 +08:00
|
|
|
|
|
|
|
|
const publishing = process.env.RELEASE_PUBLISH === 'true'
|
|
|
|
|
if (publishing) {
|
|
|
|
|
verifyPublishable(members)
|
|
|
|
|
verifyTag(family, members, process.env.GITHUB_REF ?? '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const versions = [...new Set(members.map(member => member.version))]
|
|
|
|
|
const summary = versions.length === 1 ? versions[0] : `${String(versions.length)} versions`
|
fix(release): print the publish order and the peer edges it drops
The verify step resolved the publish order and said only that it had:
the order a release actually follows, and the ordering it could not
honour, stayed invisible until a publication was already running.
publishOrder now returns that order together with the peer edges it
dropped, verify prints both, and pack reads the order off the plan. The
dropped edges are part of the result rather than a detail of forming it:
the dsh family drops one (dsh-api-remotes -> dsh-api-gateway) and the
vendored family drops two (cordis-plugin-include and
cordis-plugin-loader, which cordis declares as peers in return), and
only whoever reads the log can judge whether a newly dropped edge is
expected.
Because pack runs on every pull request and master push, a change to the
order is now reviewable there rather than observable only at publish
time.
The order is also checked against the edges it exists to honour. A cycle
mixing peer and dependency declarations can put a dependency on the
traversal stack, where it is skipped like a peer edge, emitting a
consumer before something it installs; no later step can detect that,
and it would surface as an unresolvable install for a consumer of the
published packages. No family has that shape today, and the new test
pins the three-package case that would.
2026-08-14 13:38:35 +08:00
|
|
|
console.log(
|
|
|
|
|
`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary},`
|
|
|
|
|
+ ` publish order resolved, ${String(plan.droppedPeerEdges.length)} peer declaration(s) unordered`
|
|
|
|
|
+ (publishing ? ', publish gates passed' : ''),
|
|
|
|
|
)
|
feat(release): add release family metadata, pack, verify, and publish
A release family owns its member discovery, version baseline, tag naming, and
packed-payload rule; the dsh family shares one version across packages/ and
apps/, while every vendor/ package keeps its own version line. Publish order is
topological over runtime dependencies so no package reaches the registry before
one it depends on.
pack packs the whole family into one directory and records the upload order;
publish decides per package against the registry, skipping a version whose
published tarball has the same integrity and failing when it differs, which is
what makes re-running publish over one artifact safe.
The vendored packages keep upstream's payload: their manifests export ./src/*,
so the harness rule that rejects sources and declaration maps would publish an
export map pointing at absent files.
2026-08-10 23:35:23 +08:00
|
|
|
}
|
|
|
|
|
|
fix(release): close the review findings on the release sequences
The root manifest carries the dsh family version. bump writes it with the
members, because the workspace constraint requires them to match, and that
constraint now accepts a prerelease segment: without both, release:dsh 0.0.2
left the root behind and 0.0.1-rc.1 could satisfy neither check.
The Landlock workflow no longer passes --access public, which overrode the
restricted publishConfig this repository just adopted for those packages.
Vendored change detection reads build inputs when a package publishes build
output, and vendor/cordis publishes the src its export map already pointed at:
its lib/ is untracked, so a real source edit read as 'nothing changed' and the
next publish would fail on a version whose bytes moved. The next version also
takes the last published version as its baseline, so a re-sync that restores a
lower upstream version cannot recompute a version already on the registry, and
bump confirms the registry carries what the newest tag names.
Tag prefixes are constructed rather than recovered from a full tag, which a
hyphenated version defeated. Pack runs group per ref so concurrent pull requests
stop displacing each other, the publish job carries the global group, and the
unused id-token permission is gone.
Every release script sits behind an entry guard, which is what lets the pure
judgements carry tests: tag naming, publish order and cycle reporting, version
arithmetic, payload policy, and the change judgement.
The Agent Note moves to implemented and states what shipped: one probe command,
the registry confirmation that now exists, and byte reproducibility recorded as
assumed rather than measured.
2026-08-11 01:26:36 +08:00
|
|
|
if (isEntry(import.meta.url)) main()
|