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
|
|
|
/**
|
|
|
|
|
* Publish one packed release family from the tarballs the pack step produced.
|
|
|
|
|
*
|
|
|
|
|
* Publication is decided per package against the registry, never from a list of
|
|
|
|
|
* "what this release includes": a version the registry lacks is published, a
|
|
|
|
|
* version whose published tarball has the same integrity is skipped, and a
|
|
|
|
|
* version whose published tarball differs fails the run — that last case means
|
|
|
|
|
* the content changed without a version bump
|
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
|
|
|
*
|
|
|
|
|
* Skipping on identical integrity is what makes re-running the publish step over
|
|
|
|
|
* the same artifact safe.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { createHash } from 'node:crypto'
|
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
|
import { join, resolve } from 'node:path'
|
|
|
|
|
import { parseArgs } from 'node:util'
|
|
|
|
|
import { releaseFamily } from './families.ts'
|
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 { attempt, isEntry, run } from './process.ts'
|
2026-08-11 00:51:02 +08:00
|
|
|
import { packedIdentity, readPublishOrder } from './tarball.ts'
|
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
|
|
|
|
|
|
|
|
/** npm access level for every package this repository publishes. */
|
|
|
|
|
const ACCESS = 'restricted'
|
|
|
|
|
|
|
|
|
|
/** What the registry knows about one version. */
|
|
|
|
|
type RegistryState =
|
|
|
|
|
| { readonly kind: 'absent' }
|
|
|
|
|
| { readonly kind: 'present'; readonly integrity: string }
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The subresource integrity string npm records for a tarball.
|
|
|
|
|
* @param tarball - absolute tarball path.
|
|
|
|
|
* @returns A `sha512-<base64>` string.
|
|
|
|
|
*/
|
|
|
|
|
function integrityOf(tarball: string): string {
|
|
|
|
|
return `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ask the registry whether a version exists, and with what integrity.
|
|
|
|
|
* @param name - package name.
|
|
|
|
|
* @param version - package version.
|
|
|
|
|
* @returns The registry state for that version.
|
|
|
|
|
*/
|
|
|
|
|
function registryState(name: string, version: string): RegistryState {
|
2026-08-11 00:51:02 +08:00
|
|
|
const result = attempt('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'])
|
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
|
|
|
if (result.status !== 0) {
|
|
|
|
|
const output = `${result.stdout}${result.stderr}`
|
|
|
|
|
if (output.includes('E404') || output.includes('404 Not Found')) return { kind: 'absent' }
|
|
|
|
|
throw new Error(`npm view ${name}@${version} failed:\n${output}`)
|
|
|
|
|
}
|
|
|
|
|
const parsed: unknown = JSON.parse(result.stdout)
|
|
|
|
|
if (typeof parsed !== 'string' || parsed === '') {
|
|
|
|
|
throw new Error(`registry reported no dist.integrity for ${name}@${version}`)
|
|
|
|
|
}
|
|
|
|
|
return { kind: 'present', integrity: parsed }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Publish the family named by `--family` from the directory named by `--from`. */
|
|
|
|
|
function main(): void {
|
|
|
|
|
const { values } = parseArgs({
|
|
|
|
|
options: { family: { type: 'string' }, from: { type: 'string' } },
|
|
|
|
|
allowPositionals: false,
|
|
|
|
|
})
|
|
|
|
|
if (values.family === undefined || values.from === undefined) {
|
|
|
|
|
throw new Error('usage: publish.ts --family <dsh|vendor> --from <packed directory>')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const family = releaseFamily(values.family)
|
|
|
|
|
const directory = resolve(process.cwd(), values.from)
|
|
|
|
|
|
|
|
|
|
let published = 0
|
|
|
|
|
let skipped = 0
|
2026-08-11 00:51:02 +08:00
|
|
|
for (const filename of readPublishOrder(directory)) {
|
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 tarball = join(directory, filename)
|
|
|
|
|
const { name, version } = packedIdentity(tarball)
|
|
|
|
|
const state = registryState(name, version)
|
|
|
|
|
if (state.kind === 'present') {
|
|
|
|
|
const local = integrityOf(tarball)
|
|
|
|
|
if (state.integrity !== local) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`${name}@${version} is already published with different content`
|
|
|
|
|
+ `\n registry: ${state.integrity}\n packed: ${local}`
|
|
|
|
|
+ '\nBump the version, or investigate why the build is not reproducible.',
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
console.log(`release publish: ${name}@${version} already published, skipping`)
|
|
|
|
|
skipped += 1
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-11 00:51:02 +08:00
|
|
|
// A prerelease version never takes the latest dist-tag.
|
|
|
|
|
const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
|
|
|
|
|
run('npm', ['publish', tarball, '--access', ACCESS, ...tagArgs])
|
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
|
|
|
published += 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`)
|
|
|
|
|
}
|
|
|
|
|
|
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()
|