fix(release): refuse to re-publish a version that already shipped
The feed publisher overwrote whatever version-specific assets it found, so a re-run of an old release tag could quietly replace the installer that customers already downloaded under that version number. Publication now compares the bytes already in the version-specific registry path and stops when they differ, while still allowing an identical re-run to finish. The metadata verifier gained a negative case so the guard cannot be removed unnoticed.
This commit is contained in:
parent
c3ddd36c6f
commit
49a4c97923
2 changed files with 35 additions and 1 deletions
|
|
@ -98,6 +98,11 @@ if (dryRun) {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 0) 이미 게시된 버전은 재게시하지 않는다 (SemVer 동일 버전 재릴리스 금지).
|
||||||
|
// 버전별 경로에 같은 크기의 자산이 있으면 재시도/재실행으로 보고 통과시키고,
|
||||||
|
// 다른 바이트를 가진 자산이 있으면 fail-closed로 중단한다.
|
||||||
|
await assertVersionNotRepublished(sorted);
|
||||||
|
|
||||||
// 1) 버전별(immutable) 패키지 업로드
|
// 1) 버전별(immutable) 패키지 업로드
|
||||||
for (const file of sorted) {
|
for (const file of sorted) {
|
||||||
await uploadToRegistry(file, `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`, {
|
await uploadToRegistry(file, `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`, {
|
||||||
|
|
@ -188,6 +193,24 @@ async function sha256(path) {
|
||||||
return hash.digest("hex");
|
return hash.digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function assertVersionNotRepublished(localFiles) {
|
||||||
|
for (const file of localFiles) {
|
||||||
|
const url = `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`;
|
||||||
|
const head = await forgejoFetch(url, { method: "HEAD" }).catch(() => null);
|
||||||
|
if (!head || !head.ok) continue;
|
||||||
|
|
||||||
|
const remoteLength = Number(head.headers.get("content-length"));
|
||||||
|
const localLength = (await stat(file.path)).size;
|
||||||
|
if (!Number.isFinite(remoteLength) || remoteLength === localLength) continue;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`${tag} is already published with different bytes (${safeAssetName(file.name)}: ` +
|
||||||
|
`remote ${remoteLength} bytes, local ${localLength} bytes). Releases are immutable — ` +
|
||||||
|
"lift the version and publish a new tag instead of re-publishing this one.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadToRegistry(file, url, { replace = false, immutable = false } = {}) {
|
async function uploadToRegistry(file, url, { replace = false, immutable = false } = {}) {
|
||||||
const fileStat = await stat(file.path).catch(() => null);
|
const fileStat = await stat(file.path).catch(() => null);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,10 @@ function validate(surfaces) {
|
||||||
fail(surfaces.forgejoPublisher.includes('verifyPublicFile'), 'forgejo_publisher_public_verification_missing')
|
fail(surfaces.forgejoPublisher.includes('verifyPublicFile'), 'forgejo_publisher_public_verification_missing')
|
||||||
fail(surfaces.forgejoPublisher.includes('update-policy.json'), 'forgejo_publisher_policy_upload_missing')
|
fail(surfaces.forgejoPublisher.includes('update-policy.json'), 'forgejo_publisher_policy_upload_missing')
|
||||||
fail(surfaces.forgejoPublisher.includes('CHANGELOG.md'), 'forgejo_publisher_changelog_gate_missing')
|
fail(surfaces.forgejoPublisher.includes('CHANGELOG.md'), 'forgejo_publisher_changelog_gate_missing')
|
||||||
|
fail(
|
||||||
|
surfaces.forgejoPublisher.includes('assertVersionNotRepublished'),
|
||||||
|
'forgejo_publisher_rerelease_guard_missing',
|
||||||
|
)
|
||||||
fail(
|
fail(
|
||||||
surfaces.forgejoPublisher.includes('/api/packages/') &&
|
surfaces.forgejoPublisher.includes('/api/packages/') &&
|
||||||
surfaces.forgejoPublisher.includes('generic'),
|
surfaces.forgejoPublisher.includes('generic'),
|
||||||
|
|
@ -309,6 +313,13 @@ if (process.argv.includes('--self-test')) {
|
||||||
},
|
},
|
||||||
'forgejo_publisher_asset_first_order_missing',
|
'forgejo_publisher_asset_first_order_missing',
|
||||||
)
|
)
|
||||||
|
expectRejected(
|
||||||
|
surfaces,
|
||||||
|
(candidate) => {
|
||||||
|
candidate.forgejoPublisher = candidate.forgejoPublisher.replaceAll('assertVersionNotRepublished', 'uploadAnyway')
|
||||||
|
},
|
||||||
|
'forgejo_publisher_rerelease_guard_missing',
|
||||||
|
)
|
||||||
expectRejected(
|
expectRejected(
|
||||||
surfaces,
|
surfaces,
|
||||||
(candidate) => {
|
(candidate) => {
|
||||||
|
|
@ -373,7 +384,7 @@ if (process.argv.includes('--self-test')) {
|
||||||
if (!missingDesktopKeyRejected) {
|
if (!missingDesktopKeyRejected) {
|
||||||
throw new Error('release_metadata_self_test_failed:desktop_license_public_key_missing')
|
throw new Error('release_metadata_self_test_failed:desktop_license_public_key_missing')
|
||||||
}
|
}
|
||||||
result.negativeCases = 13
|
result.negativeCases = 14
|
||||||
}
|
}
|
||||||
|
|
||||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue