161 lines
5.5 KiB
JavaScript
161 lines
5.5 KiB
JavaScript
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { assertReleaseFragment } from './fragment-contract.mjs'
|
|
import { artifactKey, artifactPrefix, fragmentKey, legacyStatusPrefix } from './storage-layout.mjs'
|
|
import { createReleaseStorageClient } from '../storage/client.mjs'
|
|
import { fetchWithRetry } from '../shared/http.mjs'
|
|
import { parseReleaseTag } from '../tag.mjs'
|
|
import { isReleaseChannelCurrent } from '../shared/live-tag.mjs'
|
|
|
|
const ROOT = process.cwd()
|
|
const args = process.argv.slice(2)
|
|
const tagIndex = args.indexOf('--tag')
|
|
const releaseTag = tagIndex >= 0 ? args[tagIndex + 1] || '' : ''
|
|
try {
|
|
parseReleaseTag(releaseTag, 'windows')
|
|
} catch {
|
|
throw new Error('usage: node scripts/release/r2/finalize-windows.mjs --tag <windows-v1.2.3>')
|
|
}
|
|
|
|
const { aws, bucket, publicBaseUrl, listKeys, deleteKeys, label } =
|
|
await createReleaseStorageClient(ROOT)
|
|
const workDirectory = await mkdtemp(path.join(os.tmpdir(), `ncurator-windows-${releaseTag}-`))
|
|
|
|
try {
|
|
const fragmentPath = path.join(workDirectory, 'windows-x64.json')
|
|
await aws([
|
|
's3',
|
|
'cp',
|
|
`s3://${bucket}/${fragmentKey(releaseTag, 'windows-x64')}`,
|
|
fragmentPath,
|
|
'--no-progress'
|
|
])
|
|
const fragment = JSON.parse(await readFile(fragmentPath, 'utf8'))
|
|
assertReleaseFragment(fragment, { tag: releaseTag, platform: 'windows-x64' })
|
|
for (const file of fragment.files) {
|
|
const key = artifactKey(fragment, file.name)
|
|
const remote = JSON.parse(
|
|
(await aws(['s3api', 'head-object', '--bucket', bucket, '--key', key, '--output', 'json']))
|
|
.stdout
|
|
)
|
|
if (Number(remote.ContentLength) !== file.size || remote.Metadata?.sha512 !== file.sha512) {
|
|
throw new Error(`${label} object does not match release fragment: ${key}`)
|
|
}
|
|
}
|
|
|
|
await isReleaseChannelCurrent(fragment, publicBaseUrl)
|
|
|
|
const metadataInput = path.join(workDirectory, 'metadata-input')
|
|
const metadataOutput = path.join(workDirectory, 'metadata-output')
|
|
await mkdir(metadataInput, { recursive: true })
|
|
await writeFile(path.join(metadataInput, 'latest.yml'), fragment.metadata, 'utf8')
|
|
const { execFile } = await import('node:child_process')
|
|
await new Promise((resolve, reject) => {
|
|
const child = execFile(process.execPath, [
|
|
path.join(ROOT, 'scripts', 'release', 'r2', 'metadata.mjs'),
|
|
metadataInput,
|
|
metadataOutput,
|
|
releaseTag,
|
|
fragmentPath
|
|
])
|
|
child.once('error', reject)
|
|
child.once('exit', (code) =>
|
|
code === 0 ? resolve() : reject(new Error(`metadata preparation exited with ${code}`))
|
|
)
|
|
})
|
|
await aws([
|
|
's3',
|
|
'cp',
|
|
path.join(metadataOutput, 'latest.yml'),
|
|
`s3://${bucket}/updates/latest/latest.yml`,
|
|
'--content-type',
|
|
'application/yaml',
|
|
'--cache-control',
|
|
'no-store',
|
|
'--no-progress'
|
|
])
|
|
|
|
const installer = fragment.files.find((file) => file.role === 'installer')
|
|
const installerKey = artifactKey(fragment, installer.name)
|
|
const installerUrl = `${publicBaseUrl}/${installerKey}`
|
|
|
|
const manifest = {
|
|
schemaVersion: 2,
|
|
platform: 'windows',
|
|
version: fragment.version,
|
|
tag: fragment.tag,
|
|
publishedAt: new Date().toISOString(),
|
|
downloads: {
|
|
x64: {
|
|
name: installer.name,
|
|
url: installerUrl,
|
|
size: installer.size,
|
|
sha512: installer.sha512
|
|
}
|
|
}
|
|
}
|
|
const manifestPath = path.join(workDirectory, 'latest.json')
|
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
await aws([
|
|
's3',
|
|
'cp',
|
|
manifestPath,
|
|
`s3://${bucket}/releases/latest/windows.json`,
|
|
'--content-type',
|
|
'application/json',
|
|
'--cache-control',
|
|
'no-store',
|
|
'--no-progress'
|
|
])
|
|
|
|
const [metadataResponse, installerResponse, manifestResponse] = await Promise.all([
|
|
fetchWithRetry(`${publicBaseUrl}/updates/latest/latest.yml?check=${Date.now()}`, {
|
|
cache: 'no-store'
|
|
}),
|
|
fetchWithRetry(`${installerUrl}?check=${Date.now()}`, {
|
|
method: 'HEAD',
|
|
cache: 'no-store'
|
|
}),
|
|
fetchWithRetry(`${publicBaseUrl}/releases/latest/windows.json?check=${Date.now()}`, {
|
|
cache: 'no-store'
|
|
})
|
|
])
|
|
if (
|
|
!metadataResponse.ok ||
|
|
!(await metadataResponse.text()).includes(`version: ${fragment.version}`)
|
|
) {
|
|
throw new Error('public Windows update metadata verification failed')
|
|
}
|
|
if (
|
|
!installerResponse.ok ||
|
|
Number(installerResponse.headers.get('content-length')) !== installer.size
|
|
) {
|
|
throw new Error('public Windows artifact download verification failed')
|
|
}
|
|
if (
|
|
!installerUrl.endsWith(`/${encodeURI(installer.name)}`) &&
|
|
!installerUrl.endsWith(`/${installer.name}`)
|
|
) {
|
|
throw new Error(`Windows installer URL must end with the user-visible name: ${installerUrl}`)
|
|
}
|
|
const publicManifest = manifestResponse.ok ? await manifestResponse.json() : undefined
|
|
if (
|
|
publicManifest?.version !== fragment.version ||
|
|
publicManifest?.downloads?.x64?.name !== installer.name ||
|
|
publicManifest?.downloads?.x64?.url !== installerUrl
|
|
) {
|
|
throw new Error('public Windows release manifest verification failed')
|
|
}
|
|
|
|
const removable = (await listKeys(artifactPrefix('windows'))).filter(
|
|
(key) => !key.startsWith(artifactPrefix('windows', releaseTag))
|
|
)
|
|
removable.push(...(await listKeys(legacyStatusPrefix('windows'))))
|
|
await deleteKeys(removable)
|
|
process.stdout.write(`[release] Windows ${releaseTag} is live at ${installerUrl}\n`)
|
|
} finally {
|
|
await rm(workDirectory, { recursive: true, force: true })
|
|
}
|