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'
|
fix(release): make publication retry, space out, and skip what landed
A landlock publication failed with `E409 Failed to save packument` on the
second of three packages. The registry answers a write it could not commit that
way, and publishing several packages back to back is what provokes it.
Neither publish path could recover. The native sequence published from a shell
loop of bare `npm publish` calls: no retry, and no way to resume, because the
registry rejects a repeat of an existing version permanently — so a failure
partway through left the release stuck. publish.ts skipped versions already
present, which made a re-run safe, but had no retry either.
Both paths now attempt a tarball up to four times, space writes at least two
seconds apart, and back off 2s/4s/8s between attempts. Every retry re-reads the
registry first, because a reported failure can answer a write that landed
anyway: a version that now exists with this tarball's integrity counts as
published rather than as one to place again. That same re-read is what turns a
mid-run `E403 cannot publish over the previously published versions` into a
skip when the bytes match, and leaves it a hard failure when they do not.
The native sequence gets the registry comparison publish.ts already had, through
its own script rather than shared code — the two sequences keep separate
publication paths. Its publish job now checks out the repository, which the
shell loop did not need.
Verified against a scripted registry: a clean publish, one E409 then success, an
E409 whose write landed anyway, E409 on every attempt (fails after four), and a
version already present with matching integrity (publishes nothing).
2026-08-13 15:18:03 +08:00
|
|
|
import { setTimeout as sleep } from 'node:timers/promises'
|
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'
|
|
|
|
|
import { releaseFamily } from './families.ts'
|
fix(release): state what the echo helper does, and drop two dead claims
Three corrections from review, none of which change behaviour.
attemptStreaming promised output "as the command produces it", which
spawnSync cannot do: it returns only after the child exits, and the two
streams are echoed one after the other, so their interleaving is lost.
For an npm publish that is visible — notices go to stderr while the
`+ name@version` confirmation goes to stdout, so the confirmation prints
first. The helper is now attemptEchoed and its contract says buffered,
echoed after exit, stdout before stderr; live progress would need an
asynchronous spawn with data listeners.
The traversal comment claimed a node on the stack is a cycle only peer
edges can form, and that skipping it drops just that edge. The cycle does
carry a peer edge, because the install edges were proved acyclic a moment
earlier, but the back edge that reaches the stacked node need not be the
peer one — which is what the post-condition exists to catch, so the
comment now points at it instead of asserting an invariant the traversal
does not have.
The `if (placed.has(member.name)) return` after leaving the stack was
unreachable: a re-entrant visit returns at the top guard while the member
is on the stack, so it can never be placed by the time the recursion
unwinds.
2026-08-14 15:14:16 +08:00
|
|
|
import { attempt, attemptEchoed, isEntry } 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
|
|
|
|
fix(release): make publication retry, space out, and skip what landed
A landlock publication failed with `E409 Failed to save packument` on the
second of three packages. The registry answers a write it could not commit that
way, and publishing several packages back to back is what provokes it.
Neither publish path could recover. The native sequence published from a shell
loop of bare `npm publish` calls: no retry, and no way to resume, because the
registry rejects a repeat of an existing version permanently — so a failure
partway through left the release stuck. publish.ts skipped versions already
present, which made a re-run safe, but had no retry either.
Both paths now attempt a tarball up to four times, space writes at least two
seconds apart, and back off 2s/4s/8s between attempts. Every retry re-reads the
registry first, because a reported failure can answer a write that landed
anyway: a version that now exists with this tarball's integrity counts as
published rather than as one to place again. That same re-read is what turns a
mid-run `E403 cannot publish over the previously published versions` into a
skip when the bytes match, and leaves it a hard failure when they do not.
The native sequence gets the registry comparison publish.ts already had, through
its own script rather than shared code — the two sequences keep separate
publication paths. Its publish job now checks out the repository, which the
shell loop did not need.
Verified against a scripted registry: a clean publish, one E409 then success, an
E409 whose write landed anyway, E409 on every attempt (fails after four), and a
version already present with matching integrity (publishes nothing).
2026-08-13 15:18:03 +08:00
|
|
|
/**
|
|
|
|
|
* Registry codes that answer a write which did not settle, rather than a
|
|
|
|
|
* rejection of what was sent. `E409 Failed to save packument` is the one this
|
|
|
|
|
* sequence actually hits: publishing several packages in a row can outrun the
|
|
|
|
|
* registry's own processing. A rejected payload (`E403` over an existing
|
|
|
|
|
* version, a malformed manifest) never clears on a retry and must surface.
|
|
|
|
|
*/
|
|
|
|
|
const TRANSIENT_PUBLISH_CODES = ['E409', 'E429', 'E500', 'E502', 'E503', 'E504', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'] as const
|
|
|
|
|
|
|
|
|
|
/** How many times one tarball's publish is attempted before the run fails. */
|
|
|
|
|
const PUBLISH_ATTEMPTS = 4
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Shortest gap between two publishes, and the first retry backoff.
|
|
|
|
|
*
|
|
|
|
|
* The registry needs a moment to commit a packument before the next write; back
|
|
|
|
|
* to back publishes are what produce `E409`.
|
|
|
|
|
*/
|
|
|
|
|
const PUBLISH_SPACING_MS = 2_000
|
|
|
|
|
|
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
|
|
|
/** What the registry knows about one version. */
|
|
|
|
|
type RegistryState =
|
|
|
|
|
| { readonly kind: 'absent' }
|
|
|
|
|
| { readonly kind: 'present'; readonly integrity: string }
|
|
|
|
|
|
fix(release): make publication retry, space out, and skip what landed
A landlock publication failed with `E409 Failed to save packument` on the
second of three packages. The registry answers a write it could not commit that
way, and publishing several packages back to back is what provokes it.
Neither publish path could recover. The native sequence published from a shell
loop of bare `npm publish` calls: no retry, and no way to resume, because the
registry rejects a repeat of an existing version permanently — so a failure
partway through left the release stuck. publish.ts skipped versions already
present, which made a re-run safe, but had no retry either.
Both paths now attempt a tarball up to four times, space writes at least two
seconds apart, and back off 2s/4s/8s between attempts. Every retry re-reads the
registry first, because a reported failure can answer a write that landed
anyway: a version that now exists with this tarball's integrity counts as
published rather than as one to place again. That same re-read is what turns a
mid-run `E403 cannot publish over the previously published versions` into a
skip when the bytes match, and leaves it a hard failure when they do not.
The native sequence gets the registry comparison publish.ts already had, through
its own script rather than shared code — the two sequences keep separate
publication paths. Its publish job now checks out the repository, which the
shell loop did not need.
Verified against a scripted registry: a clean publish, one E409 then success, an
E409 whose write landed anyway, E409 on every attempt (fails after four), and a
version already present with matching integrity (publishes nothing).
2026-08-13 15:18:03 +08:00
|
|
|
/**
|
|
|
|
|
* Whether a failed publish is worth another attempt.
|
|
|
|
|
* @param output - combined npm output.
|
|
|
|
|
* @returns True when the registry reported a write it did not commit.
|
|
|
|
|
*/
|
|
|
|
|
function isTransientFailure(output: string): boolean {
|
|
|
|
|
return TRANSIENT_PUBLISH_CODES.some(code => output.includes(`code ${code}`))
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* 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 }
|
|
|
|
|
}
|
|
|
|
|
|
fix(release): make publication retry, space out, and skip what landed
A landlock publication failed with `E409 Failed to save packument` on the
second of three packages. The registry answers a write it could not commit that
way, and publishing several packages back to back is what provokes it.
Neither publish path could recover. The native sequence published from a shell
loop of bare `npm publish` calls: no retry, and no way to resume, because the
registry rejects a repeat of an existing version permanently — so a failure
partway through left the release stuck. publish.ts skipped versions already
present, which made a re-run safe, but had no retry either.
Both paths now attempt a tarball up to four times, space writes at least two
seconds apart, and back off 2s/4s/8s between attempts. Every retry re-reads the
registry first, because a reported failure can answer a write that landed
anyway: a version that now exists with this tarball's integrity counts as
published rather than as one to place again. That same re-read is what turns a
mid-run `E403 cannot publish over the previously published versions` into a
skip when the bytes match, and leaves it a hard failure when they do not.
The native sequence gets the registry comparison publish.ts already had, through
its own script rather than shared code — the two sequences keep separate
publication paths. Its publish job now checks out the repository, which the
shell loop did not need.
Verified against a scripted registry: a clean publish, one E409 then success, an
E409 whose write landed anyway, E409 on every attempt (fails after four), and a
version already present with matching integrity (publishes nothing).
2026-08-13 15:18:03 +08:00
|
|
|
/**
|
|
|
|
|
* Publish one tarball, retrying a registry write that did not settle.
|
|
|
|
|
*
|
|
|
|
|
* Every retry re-reads the registry first, because `E409` can answer a write
|
|
|
|
|
* that landed anyway: republishing a version that now exists fails permanently,
|
|
|
|
|
* so the same integrity appearing under the failed attempt counts as success.
|
|
|
|
|
* @param tarball - absolute tarball path.
|
|
|
|
|
* @param name - package name the tarball declares.
|
|
|
|
|
* @param version - package version the tarball declares.
|
|
|
|
|
*/
|
|
|
|
|
async function publishTarball(tarball: string, name: string, version: string): Promise<void> {
|
|
|
|
|
// A prerelease version never takes the latest dist-tag.
|
|
|
|
|
const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
|
|
|
|
|
for (let tries = 1; tries <= PUBLISH_ATTEMPTS; tries += 1) {
|
|
|
|
|
// No --access: the sequences do not share one access level, so a
|
|
|
|
|
// command-line flag could not serve both and would override the manifest
|
|
|
|
|
// that does. Each packed manifest decides, and
|
|
|
|
|
// check-workspace-constraints holds every manifest to its sequence's level.
|
fix(release): state what the echo helper does, and drop two dead claims
Three corrections from review, none of which change behaviour.
attemptStreaming promised output "as the command produces it", which
spawnSync cannot do: it returns only after the child exits, and the two
streams are echoed one after the other, so their interleaving is lost.
For an npm publish that is visible — notices go to stderr while the
`+ name@version` confirmation goes to stdout, so the confirmation prints
first. The helper is now attemptEchoed and its contract says buffered,
echoed after exit, stdout before stderr; live progress would need an
asynchronous spawn with data listeners.
The traversal comment claimed a node on the stack is a cycle only peer
edges can form, and that skipping it drops just that edge. The cycle does
carry a peer edge, because the install edges were proved acyclic a moment
earlier, but the back edge that reaches the stacked node need not be the
peer one — which is what the post-condition exists to catch, so the
comment now points at it instead of asserting an invariant the traversal
does not have.
The `if (placed.has(member.name)) return` after leaving the stack was
unreachable: a re-entrant visit returns at the top guard while the member
is on the stack, so it can never be placed by the time the recursion
unwinds.
2026-08-14 15:14:16 +08:00
|
|
|
const result = attemptEchoed('npm', ['publish', tarball, ...tagArgs])
|
fix(release): make publication retry, space out, and skip what landed
A landlock publication failed with `E409 Failed to save packument` on the
second of three packages. The registry answers a write it could not commit that
way, and publishing several packages back to back is what provokes it.
Neither publish path could recover. The native sequence published from a shell
loop of bare `npm publish` calls: no retry, and no way to resume, because the
registry rejects a repeat of an existing version permanently — so a failure
partway through left the release stuck. publish.ts skipped versions already
present, which made a re-run safe, but had no retry either.
Both paths now attempt a tarball up to four times, space writes at least two
seconds apart, and back off 2s/4s/8s between attempts. Every retry re-reads the
registry first, because a reported failure can answer a write that landed
anyway: a version that now exists with this tarball's integrity counts as
published rather than as one to place again. That same re-read is what turns a
mid-run `E403 cannot publish over the previously published versions` into a
skip when the bytes match, and leaves it a hard failure when they do not.
The native sequence gets the registry comparison publish.ts already had, through
its own script rather than shared code — the two sequences keep separate
publication paths. Its publish job now checks out the repository, which the
shell loop did not need.
Verified against a scripted registry: a clean publish, one E409 then success, an
E409 whose write landed anyway, E409 on every attempt (fails after four), and a
version already present with matching integrity (publishes nothing).
2026-08-13 15:18:03 +08:00
|
|
|
const output = `${result.stdout}${result.stderr}`
|
|
|
|
|
if (result.status === 0) return
|
|
|
|
|
|
|
|
|
|
const settled = registryState(name, version)
|
|
|
|
|
if (settled.kind === 'present' && settled.integrity === integrityOf(tarball)) {
|
|
|
|
|
console.log(`release publish: ${name}@${version} landed despite a reported failure, continuing`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (tries === PUBLISH_ATTEMPTS || !isTransientFailure(output)) {
|
|
|
|
|
throw new Error(`npm publish ${name}@${version} failed:\n${output}`)
|
|
|
|
|
}
|
|
|
|
|
const backoff = PUBLISH_SPACING_MS * 2 ** (tries - 1)
|
|
|
|
|
console.log(
|
|
|
|
|
`release publish: ${name}@${version} hit a transient registry failure`
|
|
|
|
|
+ ` (attempt ${String(tries)} of ${String(PUBLISH_ATTEMPTS)}), retrying in ${String(backoff)}ms`,
|
|
|
|
|
)
|
|
|
|
|
await sleep(backoff)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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 the family named by `--family` from the directory named by `--from`. */
|
fix(release): make publication retry, space out, and skip what landed
A landlock publication failed with `E409 Failed to save packument` on the
second of three packages. The registry answers a write it could not commit that
way, and publishing several packages back to back is what provokes it.
Neither publish path could recover. The native sequence published from a shell
loop of bare `npm publish` calls: no retry, and no way to resume, because the
registry rejects a repeat of an existing version permanently — so a failure
partway through left the release stuck. publish.ts skipped versions already
present, which made a re-run safe, but had no retry either.
Both paths now attempt a tarball up to four times, space writes at least two
seconds apart, and back off 2s/4s/8s between attempts. Every retry re-reads the
registry first, because a reported failure can answer a write that landed
anyway: a version that now exists with this tarball's integrity counts as
published rather than as one to place again. That same re-read is what turns a
mid-run `E403 cannot publish over the previously published versions` into a
skip when the bytes match, and leaves it a hard failure when they do not.
The native sequence gets the registry comparison publish.ts already had, through
its own script rather than shared code — the two sequences keep separate
publication paths. Its publish job now checks out the repository, which the
shell loop did not need.
Verified against a scripted registry: a clean publish, one E409 then success, an
E409 whose write landed anyway, E409 on every attempt (fails after four), and a
version already present with matching integrity (publishes nothing).
2026-08-13 15:18:03 +08:00
|
|
|
async function main(): Promise<void> {
|
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 { 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)
|
|
|
|
|
|
2026-08-14 15:19:02 +08:00
|
|
|
// Every entry in the order settles as either published or already present, so
|
|
|
|
|
// one counter answers "how far along is this run" for whoever is watching a
|
|
|
|
|
// release that takes minutes per family.
|
|
|
|
|
const order = readPublishOrder(directory)
|
|
|
|
|
const total = String(order.length)
|
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
|
|
|
let published = 0
|
|
|
|
|
let skipped = 0
|
2026-08-14 15:19:02 +08:00
|
|
|
for (const [index, filename] of order.entries()) {
|
|
|
|
|
const progress = `[${String(index + 1)}/${total}]`
|
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.',
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-08-14 15:19:02 +08:00
|
|
|
console.log(`release publish: ${progress} ${name}@${version} already published, skipping`)
|
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
|
|
|
skipped += 1
|
|
|
|
|
continue
|
|
|
|
|
}
|
fix(release): make publication retry, space out, and skip what landed
A landlock publication failed with `E409 Failed to save packument` on the
second of three packages. The registry answers a write it could not commit that
way, and publishing several packages back to back is what provokes it.
Neither publish path could recover. The native sequence published from a shell
loop of bare `npm publish` calls: no retry, and no way to resume, because the
registry rejects a repeat of an existing version permanently — so a failure
partway through left the release stuck. publish.ts skipped versions already
present, which made a re-run safe, but had no retry either.
Both paths now attempt a tarball up to four times, space writes at least two
seconds apart, and back off 2s/4s/8s between attempts. Every retry re-reads the
registry first, because a reported failure can answer a write that landed
anyway: a version that now exists with this tarball's integrity counts as
published rather than as one to place again. That same re-read is what turns a
mid-run `E403 cannot publish over the previously published versions` into a
skip when the bytes match, and leaves it a hard failure when they do not.
The native sequence gets the registry comparison publish.ts already had, through
its own script rather than shared code — the two sequences keep separate
publication paths. Its publish job now checks out the repository, which the
shell loop did not need.
Verified against a scripted registry: a clean publish, one E409 then success, an
E409 whose write landed anyway, E409 on every attempt (fails after four), and a
version already present with matching integrity (publishes nothing).
2026-08-13 15:18:03 +08:00
|
|
|
// Space out the writes: the gap belongs between publishes, so a run that
|
|
|
|
|
// only skips does not wait at all.
|
|
|
|
|
if (published > 0) await sleep(PUBLISH_SPACING_MS)
|
|
|
|
|
await publishTarball(tarball, name, version)
|
2026-08-14 15:19:02 +08:00
|
|
|
console.log(`release publish: ${progress} ${name}@${version} published`)
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 15:19:02 +08:00
|
|
|
console.log(
|
|
|
|
|
`release publish: family ${family.id}, ${total} member(s),`
|
|
|
|
|
+ ` ${String(published)} published, ${String(skipped)} already present`,
|
|
|
|
|
)
|
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): make publication retry, space out, and skip what landed
A landlock publication failed with `E409 Failed to save packument` on the
second of three packages. The registry answers a write it could not commit that
way, and publishing several packages back to back is what provokes it.
Neither publish path could recover. The native sequence published from a shell
loop of bare `npm publish` calls: no retry, and no way to resume, because the
registry rejects a repeat of an existing version permanently — so a failure
partway through left the release stuck. publish.ts skipped versions already
present, which made a re-run safe, but had no retry either.
Both paths now attempt a tarball up to four times, space writes at least two
seconds apart, and back off 2s/4s/8s between attempts. Every retry re-reads the
registry first, because a reported failure can answer a write that landed
anyway: a version that now exists with this tarball's integrity counts as
published rather than as one to place again. That same re-read is what turns a
mid-run `E403 cannot publish over the previously published versions` into a
skip when the bytes match, and leaves it a hard failure when they do not.
The native sequence gets the registry comparison publish.ts already had, through
its own script rather than shared code — the two sequences keep separate
publication paths. Its publish job now checks out the repository, which the
shell loop did not need.
Verified against a scripted registry: a clean publish, one E409 then success, an
E409 whose write landed anyway, E409 on every attempt (fails after four), and a
version already present with matching integrity (publishes nothing).
2026-08-13 15:18:03 +08:00
|
|
|
if (isEntry(import.meta.url)) await main()
|