Route packaged macOS bundles and Light through the updater strategy. Use electron-updater 6.8.9 and wait for native signature acceptance before backend teardown. Keep checkout and Store ownership separate. Share Darwin feed paths between packaging, runtime and publication. Validate both native feeds, verify streamed artifact hashes, prevent same-tag artifact replacement, and conditionally update the channel pointer. Protect live feed references during canary retention. Use one notarization owner. Require publishing credentials and validate the stapled app. Keep Windows, Linux and Termux jobs unchanged. Verified with updater/feed unit and transport tests, release-helper tests, desktop typechecks, the desktop JS build, and workflow lint. No E2E, native macOS install, release dispatch or public publication was run.
644 lines
27 KiB
JavaScript
644 lines
27 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/r2-release.mjs — zero-dependency Cloudflare R2 (S3 API) client for
|
|
// release artifacts. Runs on every release runner (node is guaranteed there
|
|
// via pm/lock.json; the notes + prune jobs need no npm ci), so the only
|
|
// dependency is node's own fetch + crypto. SigV4 signed exactly per the
|
|
// botocore-generated vectors in tests-js/r2-release.test.mjs.
|
|
//
|
|
// node scripts/r2-release.mjs put --tag vX.Y.Z --key <filename> --file <path>
|
|
// node scripts/r2-release.mjs finalize --tag vX.Y.Z --dir <staging-dir>
|
|
// node scripts/r2-release.mjs list [--prefix <p>]
|
|
// node scripts/r2-release.mjs prune-canaries --keep-days 14 [--dry-run]
|
|
//
|
|
// Env (all required except where noted):
|
|
// CLOUDFLARE_R2_ACCOUNT_ID → S3 endpoint https://<account>.r2.cloudflarestorage.com
|
|
// CLOUDFLARE_R2_ACCESS_KEY_ID R2 API token (S3-compatible)
|
|
// CLOUDFLARE_R2_SECRET_ACCESS_KEY
|
|
// CLOUDFLARE_R2_BUCKET
|
|
//
|
|
// Bucket layout (matches app-updater.ts's D1-settled arms):
|
|
// releases/tag/<tag>/<filename> immutable per-release staging/archive
|
|
// releases/win32/<channel>/<channel>.appinstaller App Installer feed
|
|
// releases/win32/<channel>/*.msixbundle (produced by the
|
|
// publish-win32-updater job)
|
|
// releases/darwin/<channel>/<channel>-mac.yml electron-updater feed
|
|
// releases/darwin/<channel>/*.{dmg,zip,blockmap}
|
|
// where <channel> is stable | canary (from the tag: -canary. → canary).
|
|
// The publish-win32-updater job merges the win32 legs' staging into the
|
|
// win32 feed; r2 finalize publishes the validated Darwin channel feed.
|
|
|
|
import { createHash, createHmac } from 'node:crypto'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
import { contentTypeFor } from './msix-shared.mjs'
|
|
|
|
// Re-exported for the r2 test (the Content-Type mapping is shared with the
|
|
// stage job; msix-shared.mjs is the single source).
|
|
export { contentTypeFor } from './msix-shared.mjs'
|
|
|
|
const REGION = 'auto'
|
|
const SERVICE = 's3'
|
|
const EMPTY_SHA = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SigV4 (pure; the test file pins these against botocore-generated vectors)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** RFC3986 encode: everything except unreserved [A-Za-z0-9-_.~]. */
|
|
export function rfc3986Encode(value) {
|
|
return encodeURIComponent(value).replace(/[!'()*]/g, (c) =>
|
|
'%' + c.charCodeAt(0).toString(16).toUpperCase(),
|
|
)
|
|
}
|
|
|
|
/** Canonical query string: params sorted by encoded key (then encoded value). */
|
|
export function canonicalQuery(params) {
|
|
return Object.entries(params)
|
|
.map(([k, v]) => [rfc3986Encode(k), rfc3986Encode(String(v))])
|
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join('&')
|
|
}
|
|
|
|
/**
|
|
* Canonical request for one S3-style request.
|
|
* `headers` is the exact header set that will be sent (host, x-amz-date,
|
|
* x-amz-content-sha256); canonicalization lowercases + sorts them.
|
|
*/
|
|
export function canonicalRequest(method, path, query, headers, payloadHash) {
|
|
const names = Object.keys(headers).map((n) => n.toLowerCase()).sort()
|
|
// Header values are read case-insensitively: keys may be mixed-case
|
|
// ('Content-Type'), but SigV4 canonicalizes the NAME to lowercase, so
|
|
// `headers[lowerName]` would miss the value. Find the original-key match.
|
|
const valueFor = (name) => {
|
|
const key = Object.keys(headers).find((k) => k.toLowerCase() === name)
|
|
return key == null ? undefined : headers[key]
|
|
}
|
|
const canonicalHeaders = names
|
|
.map((n) => `${n}:${String(valueFor(n)).trim().replace(/\s+/g, ' ')}`)
|
|
.join('\n')
|
|
return [
|
|
method,
|
|
path,
|
|
query,
|
|
canonicalHeaders,
|
|
'',
|
|
names.join(';'),
|
|
payloadHash,
|
|
].join('\n')
|
|
}
|
|
|
|
/** The AWS4 string-to-sign for a canonical request. */
|
|
export function stringToSign(canonical, date, scope) {
|
|
return ['AWS4-HMAC-SHA256', date, scope, createHash('sha256').update(canonical).digest('hex')].join('\n')
|
|
}
|
|
|
|
function hmac(key, msg) {
|
|
return createHmac('sha256', key).update(msg).digest()
|
|
}
|
|
|
|
/** Signature for a string-to-sign, given secret key + credential scope parts. */
|
|
export function signature(stringToSignText, secretKey, date, region, service) {
|
|
const kDate = hmac(`AWS4${secretKey}`, date)
|
|
const kRegion = hmac(kDate, region)
|
|
const kService = hmac(kRegion, service)
|
|
const kSigning = hmac(kService, 'aws4_request')
|
|
return hmac(kSigning, stringToSignText).toString('hex')
|
|
}
|
|
|
|
/**
|
|
* Full AWS4-HMAC-SHA256 Authorization header value for one request.
|
|
* `now` is 'YYYYMMDDTHHMMSSZ' (injectable for tests); scope date is its
|
|
* first 8 chars. `payloadHash` is hex sha256 of the body (or the empty
|
|
* string hash for bodyless requests).
|
|
*/
|
|
export function authHeader({ method, host, path, query, headers, payloadHash, accessKeyId, secretKey, now, region = REGION, service = SERVICE }) {
|
|
const date = now.slice(0, 8)
|
|
const canonical = canonicalRequest(method, path, query, headers, payloadHash)
|
|
const sts = stringToSign(canonical, now, `${date}/${region}/${service}/aws4_request`)
|
|
const sig = signature(sts, secretKey, date, region, service)
|
|
const signedHeaders = Object.keys(headers).map((n) => n.toLowerCase()).sort().join(';')
|
|
return (
|
|
`AWS4-HMAC-SHA256 Credential=${accessKeyId}/${date}/${region}/${service}/aws4_request, ` +
|
|
`SignedHeaders=${signedHeaders}, Signature=${sig}`
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// R2 request plumbing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function s3Endpoint(accountId) {
|
|
return `https://${accountId}.r2.cloudflarestorage.com`
|
|
}
|
|
|
|
/** Encode an object key into the URI path, segment by segment. */
|
|
export function encodeKeyPath(key) {
|
|
return key.split('/').map(rfc3986Encode).join('/')
|
|
}
|
|
|
|
function requiredEnv(name) {
|
|
const value = process.env[name]
|
|
if (!value) {
|
|
console.error(`::error::missing env ${name} — see the header comment in scripts/r2-release.mjs`)
|
|
process.exit(2)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function r2Headers(method, host, path, query, bodyHash, now, creds, contentType, contentLength, extraHeaders) {
|
|
const headers = {
|
|
host,
|
|
'x-amz-date': now,
|
|
'x-amz-content-sha256': bodyHash,
|
|
}
|
|
// Optional Content-Type for binary artifacts (App Installer / MSIX). It is
|
|
// added BEFORE the Authorization header is computed, so it lands in the
|
|
// SigV4 canonical headers + SignedHeaders exactly like host / x-amz-*.
|
|
if (contentType) headers['Content-Type'] = contentType
|
|
// Stream bodies (files >2GiB) need an explicit Content-Length — R2
|
|
// rejects a bodyless-length PUT with 411 MissingContentLength.
|
|
if (contentLength != null) headers['Content-Length'] = String(contentLength)
|
|
// Extra request headers (e.g. Range) join BEFORE signing so they are
|
|
// covered by SignedHeaders like every other header we send.
|
|
if (extraHeaders) Object.assign(headers, extraHeaders)
|
|
headers.authorization = authHeader({
|
|
method,
|
|
host,
|
|
path,
|
|
query,
|
|
headers,
|
|
payloadHash: bodyHash,
|
|
accessKeyId: creds.accessKeyId,
|
|
secretKey: creds.secretKey,
|
|
now,
|
|
})
|
|
return headers
|
|
}
|
|
|
|
async function signedFetch(method, url, { body, bodyHash, creds, now, contentType, contentLength, extraHeaders }) {
|
|
const { host, pathname, search } = new URL(url)
|
|
const query = search.replace(/^\?/, '')
|
|
const headers = r2Headers(method, host, pathname, query, bodyHash, now, creds, contentType, contentLength, extraHeaders)
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers,
|
|
body: body ?? undefined,
|
|
// Stream bodies (files >2GiB) require the duplex option on Node's fetch.
|
|
...(body != null && typeof body.pipe === 'function' ? { duplex: 'half' } : {}),
|
|
})
|
|
const text = await res.text()
|
|
if (!res.ok) {
|
|
console.error(`::error::R2 ${method} ${pathname} -> ${res.status}`)
|
|
if (text) console.error(text.slice(0, 2000))
|
|
}
|
|
return { res, text }
|
|
}
|
|
|
|
/** Signed ranged GET: HEAD responses lose content-length through some
|
|
* proxies; Content-Range on a 1-byte GET is the reliable size oracle. */
|
|
async function signedFetchRange(method, url, rangeHeaders, creds, now) {
|
|
return signedFetch(method, url, { bodyHash: EMPTY_SHA, creds, now, extraHeaders: rangeHeaders })
|
|
}
|
|
|
|
async function retry(fn, tries = 3) {
|
|
let lastError
|
|
for (let attempt = 1; attempt <= tries; attempt++) {
|
|
try {
|
|
return await fn()
|
|
} catch (err) {
|
|
lastError = err
|
|
if (attempt < tries) await new Promise((r) => setTimeout(r, 1000 * attempt))
|
|
}
|
|
}
|
|
throw lastError
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layout helpers (pure)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** 'stable' for a stable tag, 'canary' for a -canary.<ts> tag. */
|
|
export function channelForTag(tag) {
|
|
return /-canary\.20\d{6}(?:\d{6})?$/.test(tag) ? 'canary' : 'stable'
|
|
}
|
|
|
|
// Content-Type for MSIX / App Installer artifacts lives in msix-shared.mjs
|
|
// (single source — the same suffixes must drive the stage job's uploads).
|
|
|
|
/** Immutable per-release staging key. */
|
|
export function stagingKeyFor(tag, filename) {
|
|
return `releases/tag/${tag}/${filename}`
|
|
}
|
|
|
|
/** The feed directory key for a platform arm + channel, e.g. releases/win32/stable/. */
|
|
export function feedDirFor(platform, channel) {
|
|
return `releases/${platform}/${channel}`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** APT indexes are mutable; by-hash indexes and versioned packages are not. */
|
|
export function cacheControlFor(key) {
|
|
if (key.startsWith('releases/darwin/') && key.endsWith('-mac.yml')) return 'no-store'
|
|
if (!key.startsWith('releases/termux/')) return undefined
|
|
return key.includes('/by-hash/') || key.includes('/pool/')
|
|
? 'public, max-age=31536000, immutable'
|
|
: 'no-store'
|
|
}
|
|
|
|
async function putObject(creds, base, bucket, key, payload, now, contentType, conditions = {}) {
|
|
// `payload` is either a small in-memory Buffer (feed manifests from
|
|
// finalize) or a FILE PATH (binaries via `put`). The msixbundle is
|
|
// ~2.7GB so path payloads stream from disk (fs.readFileSync throws
|
|
// ERR_FS_FILE_TOO_LARGE past 2GiB); buffers upload directly.
|
|
const isPath = typeof payload === 'string'
|
|
const size = isPath ? (await fs.promises.stat(payload)).size : payload.length
|
|
const bodyHash = isPath
|
|
? await new Promise((resolve, reject) => {
|
|
const hash = createHash('sha256')
|
|
const stream = fs.createReadStream(payload)
|
|
stream.on('data', (c) => hash.update(c))
|
|
stream.on('end', () => resolve(hash.digest('hex')))
|
|
stream.on('error', reject)
|
|
})
|
|
: createHash('sha256').update(payload).digest('hex')
|
|
const url = `${base}/${bucket}/${encodeKeyPath(key)}`
|
|
await retry(async () => {
|
|
// Path payloads get a fresh stream per attempt: a consumed
|
|
// ReadableStream cannot be replayed for the retry ("body object
|
|
// should not be disturbed").
|
|
const body = isPath ? fs.createReadStream(payload) : payload
|
|
const cacheControl = cacheControlFor(key)
|
|
const extraHeaders = { ...conditions, ...(cacheControl ? { 'Cache-Control': cacheControl } : {}) }
|
|
const { res, text } = await signedFetch('PUT', url, { body, bodyHash, contentLength: size, creds, now, contentType, extraHeaders })
|
|
if (res.status === 412 && isPath && conditions['If-None-Match'] === '*') {
|
|
await verifyRemoteArtifact(url, creds, now, size, bodyHash, 'sha256', 'hex')
|
|
return
|
|
}
|
|
if (!res.ok) throw new Error(`PUT ${key} -> ${res.status}${text ? `: ${text.slice(0, 300)}` : ''}`)
|
|
})
|
|
// HEAD can come back without content-length (intermediaries strip it on
|
|
// HEAD more readily than on ranged GETs). Fall back to a 1-byte ranged
|
|
// GET, whose Content-Range carries the authoritative total size.
|
|
const { res: headRes } = await retry(async () => {
|
|
const r = await signedFetch('HEAD', url, { bodyHash: EMPTY_SHA, creds, now })
|
|
if (!r.res.ok) throw new Error(`HEAD ${key} -> ${r.res.status}`)
|
|
return r
|
|
})
|
|
let remoteSize = headRes.headers.get('content-length')
|
|
if (remoteSize === null) {
|
|
const rangeHeaders = { range: 'bytes=0-0' }
|
|
const range = await retry(async () => {
|
|
const r = await signedFetchRange('GET', url, rangeHeaders, creds, now)
|
|
if (!r.res.ok) throw new Error(`GET range ${key} -> ${r.res.status}`)
|
|
return r
|
|
})
|
|
const contentRange = range.res.headers.get('content-range')
|
|
const total = contentRange ? contentRange.match(/bytes 0-0\/(\d+)/) : null
|
|
if (!total) {
|
|
console.error(`::error::R2 ${key}: could not determine remote size (HEAD had no content-length, ranged GET had no content-range: ${contentRange})`)
|
|
process.exit(1)
|
|
}
|
|
remoteSize = total[1]
|
|
}
|
|
if (String(remoteSize) !== String(size)) {
|
|
console.error(`::error::R2 HEAD ${key}: size mismatch (remote ${remoteSize}, local ${size})`)
|
|
process.exit(1)
|
|
}
|
|
console.log(`✓ r2: ${key} (${size} bytes)`)
|
|
}
|
|
|
|
export async function cmdPut({ tag, key, file, keyIsFull = false }) {
|
|
const accountId = requiredEnv('CLOUDFLARE_R2_ACCOUNT_ID')
|
|
const accessKeyId = requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID')
|
|
const secretKey = requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY')
|
|
const bucket = requiredEnv('CLOUDFLARE_R2_BUCKET')
|
|
const creds = { accessKeyId, secretKey }
|
|
const base = s3Endpoint(accountId)
|
|
|
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
|
|
const keyPath = keyIsFull ? key : stagingKeyFor(tag, key)
|
|
const immutableMac = keyPath.startsWith('releases/tag/') && /-mac-(arm64|x64)\.(zip|dmg)(\.blockmap)?$/.test(keyPath)
|
|
await putObject(creds, base, bucket, keyPath, file, now, contentTypeFor(key), immutableMac ? { 'If-None-Match': '*' } : {})
|
|
}
|
|
|
|
async function verifyRemoteArtifact(urlValue, creds, now, expectedSize, digest, algorithm = 'sha512', encoding = 'base64') {
|
|
const url = new URL(urlValue)
|
|
const headers = r2Headers('GET', url.host, url.pathname, '', EMPTY_SHA, now, creds)
|
|
const response = await fetch(url, { headers, signal: AbortSignal.timeout(600_000) })
|
|
if (!response.ok || !response.body) throw new Error(`Cannot verify ${url.pathname}: ${response.status}`)
|
|
const hash = createHash(algorithm)
|
|
let size = 0
|
|
for await (const chunk of response.body) { hash.update(chunk); size += chunk.length }
|
|
if (size !== expectedSize || hash.digest(encoding) !== digest) throw new Error(`Artifact checksum mismatch: ${url.pathname}`)
|
|
}
|
|
|
|
/** Validate both native legs, verify their bytes, then replace the feed pointer. */
|
|
export async function cmdFinalize({ tag, dir, variant }) {
|
|
const { mergeMacFeeds, publishMacFeed } = await import('./darwin-feed.mjs')
|
|
if (variant && variant !== 'light') throw new Error('Unknown macOS variant')
|
|
const accountId = requiredEnv('CLOUDFLARE_R2_ACCOUNT_ID')
|
|
const creds = {
|
|
accessKeyId: requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID'),
|
|
secretKey: requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY')
|
|
}
|
|
const bucket = requiredEnv('CLOUDFLARE_R2_BUCKET')
|
|
const base = s3Endpoint(accountId)
|
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
|
|
const legs = Object.fromEntries(fs.readdirSync(dir).filter(name => name.endsWith('-mac.yml'))
|
|
.map(name => [name, fs.readFileSync(path.join(dir, name), 'utf8')]))
|
|
const plan = mergeMacFeeds(legs, tag, variant === 'light')
|
|
await publishMacFeed(plan, {
|
|
read: async key => {
|
|
const url = `${base}/${bucket}/${encodeKeyPath(key)}`
|
|
const { res, text } = await signedFetch('GET', url, { bodyHash: EMPTY_SHA, creds, now })
|
|
if (res.status === 404) return null
|
|
if (!res.ok) throw new Error(`GET ${key} -> ${res.status}`)
|
|
const etag = res.headers.get('etag')
|
|
if (!etag) throw new Error(`No ETag for ${key}`)
|
|
return { text, etag }
|
|
},
|
|
verify: async (key, file) => {
|
|
await verifyRemoteArtifact(`${base}/${bucket}/${encodeKeyPath(key)}`, creds, now, file.size, file.sha512)
|
|
},
|
|
write: (key, text, etag) => putObject(creds, base, bucket, key, Buffer.from(text), now,
|
|
'application/yaml', etag ? { 'If-Match': etag } : { 'If-None-Match': '*' })
|
|
})
|
|
console.log(`✓ r2: finalized ${tag} → ${plan.key}`)
|
|
}
|
|
|
|
/** Parse a ListObjectsV2 XML body into { keys, lastModified, truncated, nextToken }. */
|
|
export function parseListXml(xml) {
|
|
const unescape = (s) =>
|
|
s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&')
|
|
const keys = [...xml.matchAll(/<Key>([^<]+)<\/Key>/g)].map((m) => unescape(m[1]))
|
|
const lastModified = {}
|
|
for (const m of xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)) {
|
|
const key = m[1].match(/<Key>([^<]+)<\/Key>/)
|
|
const lm = m[1].match(/<LastModified>([^<]+)<\/LastModified>/)
|
|
if (key && lm) lastModified[unescape(key[1])] = Date.parse(lm[1])
|
|
}
|
|
const truncated = /<IsTruncated>true<\/IsTruncated>/.test(xml)
|
|
const tokenMatch = xml.match(/<NextContinuationToken>([^<]+)<\/NextContinuationToken>/)
|
|
return { keys, lastModified, truncated, nextToken: tokenMatch ? unescape(tokenMatch[1]) : null }
|
|
}
|
|
|
|
/** GET one object's body, or null when it does not exist / cannot be read. */
|
|
async function getObject(creds, base, bucket, key, now) {
|
|
const url = `${base}/${bucket}/${encodeKeyPath(key)}`
|
|
const { res, text } = await signedFetch('GET', url, { bodyHash: EMPTY_SHA, creds, now })
|
|
return res.ok ? text : null
|
|
}
|
|
|
|
async function listObjects(prefix = '') {
|
|
const accountId = requiredEnv('CLOUDFLARE_R2_ACCOUNT_ID')
|
|
const accessKeyId = requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID')
|
|
const secretKey = requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY')
|
|
const bucket = requiredEnv('CLOUDFLARE_R2_BUCKET')
|
|
const creds = { accessKeyId, secretKey }
|
|
const base = s3Endpoint(accountId)
|
|
|
|
const keys = []
|
|
const lastModified = {}
|
|
let token = null
|
|
for (;;) {
|
|
const params = { 'list-type': '2', 'max-keys': '1000' }
|
|
if (prefix) params.prefix = prefix
|
|
if (token) params['continuation-token'] = token
|
|
const query = canonicalQuery(params)
|
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
|
|
const url = `${base}/${bucket}?${query}`
|
|
const { res, text } = await signedFetch('GET', url, { bodyHash: EMPTY_SHA, creds, now })
|
|
if (!res.ok) process.exit(1)
|
|
const parsed = parseListXml(text)
|
|
keys.push(...parsed.keys)
|
|
Object.assign(lastModified, parsed.lastModified)
|
|
if (!parsed.truncated || !parsed.nextToken) break
|
|
token = parsed.nextToken
|
|
}
|
|
return { keys, lastModified }
|
|
}
|
|
|
|
async function cmdList({ prefix }) {
|
|
const { keys } = await listObjects(prefix)
|
|
for (const key of keys) console.log(key)
|
|
}
|
|
|
|
/** Keys whose own canary date (YYYYMMDD in the name) is before `cutoff`. */
|
|
export function canaryDoomedKeys(keys, cutoff) {
|
|
return keys.filter((key) => {
|
|
const m = key.match(/-canary\.(\d{8})/)
|
|
return m && m[1] < cutoff
|
|
})
|
|
}
|
|
|
|
/** Feed publish order: the immutable .msixbundle FIRST, the pointer LAST. */
|
|
export function publishFeedUploads(plan, upload) {
|
|
upload(`${plan.channelDir}/${plan.bundleFilename}`, plan.bundleFile)
|
|
upload(`${plan.channelDir}/${plan.appinstallerName}`, plan.appinstallerFile)
|
|
}
|
|
|
|
/**
|
|
* Bundle basenames a .appinstaller manifest still references, parsed from the
|
|
* KNOWN generated shape (MainPackage/MainBundle Uri attributes only — never
|
|
* any Uri=" in the document). An unrecognized/empty manifest returns [], and
|
|
* the pruner treats [] as "block this directory" (fail closed).
|
|
*/
|
|
function feedBundleUris(appinstallerXml) {
|
|
const xml = String(appinstallerXml || '').trim()
|
|
// Only our complete generated shape is eligible for destructive retention.
|
|
if (!/^(?:<\?xml[^?]*\?>\s*)?<AppInstaller\b[^>]*>[\s\S]*<\/AppInstaller>$/.test(xml)) return []
|
|
const elements = [...xml.matchAll(/<(?:MainPackage|MainBundle)\b[^>]*\/>/g)]
|
|
if (elements.length !== 1) return []
|
|
const uri = elements[0][0].match(/\bUri="([^"]+)"/)
|
|
return uri && /\.(?:msixbundle|msix)$/i.test(uri[1]) ? [uri[1]] : []
|
|
}
|
|
|
|
export function referencedFeedBundleFilenames(appinstallerXml) {
|
|
return feedBundleUris(appinstallerXml).map(uri => uri.split('/').pop())
|
|
}
|
|
|
|
/**
|
|
* Full bucket keys a manifest references: each referenced bundle inside its
|
|
* feed dir, plus any MainPackage/MainBundle Uri carrying an absolute path
|
|
* (e.g. /releases/tag/<tag>/… — tag-archive targets), protected by exact key.
|
|
*/
|
|
export function feedReferencedKeys(dir, appinstallerXml) {
|
|
const keys = []
|
|
for (const uri of feedBundleUris(appinstallerXml)) {
|
|
keys.push(`${dir}/${uri.split('/').pop()}`)
|
|
try {
|
|
const p = new URL(uri, 'https://placeholder.invalid').pathname
|
|
if (p.startsWith('/releases/')) keys.push(decodeURIComponent(p.slice(1)))
|
|
} catch { /* relative Uri — already covered by the basename key */ }
|
|
}
|
|
return keys
|
|
}
|
|
|
|
/**
|
|
* Canary feed-dir retention: the `-canary.YYYYMMDD` matcher cannot see feed
|
|
* bundles (numeric 4-part MSIX version names). A bundle is doomed when its
|
|
* feed dir's manifests were ALL readable AND it is referenced by none of
|
|
* them AND its actual list LastModified predates cutoffMs. Fail-closed on
|
|
* every unknown: unreadable/unrecognized manifest (zero references) blocks
|
|
* the whole dir; stable dirs and unknown dirs are never pruned; a missing
|
|
* LastModified keeps the object.
|
|
*
|
|
* @param {string[]} keys all bucket keys
|
|
* @param {Record<string, (string|null)[]>} feedXmlByDir dir → manifest texts (null = unreadable)
|
|
* @param {Record<string, number>} lastModifiedMs key → epoch ms from listObjects
|
|
* @param {number} cutoffMs
|
|
* @returns {string[]} doomed feed-dir bundle keys
|
|
*/
|
|
export function staleFeedBundleKeys(keys, feedXmlByDir, lastModifiedMs = {}, cutoffMs = -Infinity) {
|
|
const doomed = []
|
|
for (const [dir, manifests] of Object.entries(feedXmlByDir || {})) {
|
|
if (!/\/canary$/.test(dir.replace(/\/+$/, ''))) continue // canaries only
|
|
const referenced = new Set()
|
|
let blocked = false
|
|
for (const xml of manifests || []) {
|
|
const names = referencedFeedBundleFilenames(xml)
|
|
if (names.length === 0) {
|
|
// Unreadable or unrecognized manifest in this dir → prune nothing here.
|
|
blocked = true
|
|
console.warn(`::warning::feed manifest unreadable/unrecognized, skipping feed retention for ${dir}/`)
|
|
break
|
|
}
|
|
for (const name of names) referenced.add(name)
|
|
}
|
|
if (blocked || referenced.size === 0) continue
|
|
const prefix = `${dir.replace(/\/+$/, '')}/`
|
|
for (const key of keys) {
|
|
if (!key.startsWith(prefix)) continue
|
|
if (!/\.(?:msixbundle|msix)$/i.test(key)) continue // pointers + metadata stay
|
|
if (referenced.has(key.slice(prefix.length))) continue
|
|
const lm = lastModifiedMs[key]
|
|
if (!Number.isFinite(lm) || lm >= cutoffMs) continue // keep-days grace (fail-closed)
|
|
doomed.push(key)
|
|
}
|
|
}
|
|
return doomed
|
|
}
|
|
|
|
export async function cmdPrune({ keepDays, dryRun }) {
|
|
const accountId = requiredEnv('CLOUDFLARE_R2_ACCOUNT_ID')
|
|
const accessKeyId = requiredEnv('CLOUDFLARE_R2_ACCESS_KEY_ID')
|
|
const secretKey = requiredEnv('CLOUDFLARE_R2_SECRET_ACCESS_KEY')
|
|
const bucket = requiredEnv('CLOUDFLARE_R2_BUCKET')
|
|
const creds = { accessKeyId, secretKey }
|
|
const base = s3Endpoint(accountId)
|
|
|
|
// Cutoff dated by the canary suffix in the KEY (like release.py's
|
|
// --prune-canaries: a re-uploaded old tag never resets its clock).
|
|
const cutoff = new Date(Date.now() - keepDays * 86400_000).toISOString().slice(0, 10).replace(/-/g, '')
|
|
const cutoffMs = Date.now() - keepDays * 86400_000
|
|
const { keys, lastModified } = await listObjects()
|
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
|
|
|
|
// Read EVERY .appinstaller per feed dir (union of references protects the
|
|
// dir; any unreadable/unrecognized one blocks retention for that dir —
|
|
// handled inside staleFeedBundleKeys, null = unreadable).
|
|
const feedXmlByDir = {}
|
|
const protectedKeys = new Set()
|
|
for (const key of keys.filter((k) => k.endsWith('.appinstaller'))) {
|
|
const dir = key.slice(0, key.lastIndexOf('/'))
|
|
const xml = await getObject(creds, base, bucket, key, now)
|
|
if (feedBundleUris(xml).length === 0) {
|
|
throw new Error(`Cannot establish live references from ${key}; refusing to prune`)
|
|
}
|
|
;(feedXmlByDir[dir] ??= []).push(xml)
|
|
for (const k of feedReferencedKeys(dir, xml)) protectedKeys.add(k)
|
|
}
|
|
|
|
for (const key of keys.filter(key => key.startsWith('releases/darwin/') && key.endsWith('-mac.yml'))) {
|
|
const { macFeedReferences } = await import('./darwin-feed.mjs')
|
|
const text = await getObject(creds, base, bucket, key, now)
|
|
for (const reference of macFeedReferences(text)) protectedKeys.add(reference)
|
|
}
|
|
|
|
const doomed = [
|
|
...canaryDoomedKeys(keys, cutoff),
|
|
...staleFeedBundleKeys(keys, feedXmlByDir, lastModified, cutoffMs),
|
|
// Live referenced objects (incl. tag-archive targets) are never deleted.
|
|
].filter((key) => !protectedKeys.has(key))
|
|
|
|
if (doomed.length === 0) {
|
|
console.log(`✓ r2: no canary objects older than ${keepDays} days`)
|
|
return
|
|
}
|
|
for (const key of doomed.sort()) {
|
|
if (dryRun) {
|
|
console.log(`(dry-run) would delete r2:${key}`)
|
|
continue
|
|
}
|
|
const url = `${base}/${bucket}/${encodeKeyPath(key)}`
|
|
const { res } = await signedFetch('DELETE', url, { bodyHash: EMPTY_SHA, creds, now })
|
|
if (!res.ok) process.exit(1)
|
|
console.log(`deleted r2:${key}`)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function usage() {
|
|
console.error(`usage:
|
|
node scripts/r2-release.mjs put --tag vX.Y.Z --key <filename> --file <path> [--key-is-full]
|
|
node scripts/r2-release.mjs finalize --tag vX.Y.Z --dir <staging-dir>
|
|
node scripts/r2-release.mjs list [--prefix <p>]
|
|
node scripts/r2-release.mjs prune-canaries --keep-days <n> [--dry-run]
|
|
|
|
--key-is-full: the --key is a FULL object key (e.g. releases/win32/stable/…),
|
|
not a filename to archive under releases/tag/<tag>/.`)
|
|
process.exit(2)
|
|
}
|
|
|
|
export function isMain() {
|
|
return process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
|
|
}
|
|
|
|
export async function main(argv = process.argv.slice(2)) {
|
|
const [cmd, ...rest] = argv
|
|
const args = {}
|
|
for (let i = 0; i < rest.length; i++) {
|
|
const flag = rest[i]
|
|
if (['--tag', '--key', '--file', '--prefix', '--keep-days', '--dir', '--variant'].includes(flag)) {
|
|
args[flag.slice(2)] = rest[++i]
|
|
} else if (flag === '--dry-run' || flag === '--key-is-full') {
|
|
args[flag.slice(2)] = true
|
|
} else {
|
|
usage()
|
|
}
|
|
}
|
|
if (cmd === 'put') {
|
|
const tag = args.tag || process.env.HERMES_PAYLOAD_TAG
|
|
if (!tag || !args.key || !args.file) usage()
|
|
await cmdPut({ tag, key: args.key, file: args.file, keyIsFull: Boolean(args['key-is-full']) })
|
|
} else if (cmd === 'finalize') {
|
|
if (!args.tag || !args.dir) usage()
|
|
await cmdFinalize({ tag: args.tag, dir: args.dir, variant: args.variant })
|
|
} else if (cmd === 'list') {
|
|
await cmdList({ prefix: args.prefix ?? '' })
|
|
} else if (cmd === 'prune-canaries') {
|
|
const keepDays = Number(args['keep-days'])
|
|
if (!Number.isFinite(keepDays) || keepDays <= 0) usage()
|
|
// '--dry-run' parses to args['dry-run'] (flag.slice(2) keeps the dash).
|
|
await cmdPrune({ keepDays, dryRun: Boolean(args['dry-run']) })
|
|
} else {
|
|
usage()
|
|
}
|
|
}
|
|
|
|
if (isMain()) {
|
|
main().catch((err) => {
|
|
console.error(`::error::${err?.stack ?? err}`)
|
|
process.exit(1)
|
|
})
|
|
}
|