import { execFile } from 'node:child_process'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { parseStrictSemVer } from './release-metadata.mjs'
import {
readStoreChannelSnapshot,
verifySnapStoreChannel,
} from './verify-snap-store-channel.mjs'
const execFileAsync = promisify(execFile)
const ARCHITECTURES = ['amd64', 'arm64']
const SNAP_NAME_PATTERN = /^[a-z0-9](?:-?[a-z0-9])*$/
const CHANNEL_PATTERN =
/^(?<track>[A-Za-z0-9][A-Za-z0-9._-]*)\/(?<risk>stable|candidate|beta|edge)$/
const DEFAULT_SNAPCRAFT_COMMAND_TIMEOUT_MS = 120_000
function normalizeRevision(value, architecture) {
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) {
throw new Error(`${architecture} revision must be a positive integer`)
}
return value
}
function normalizeRevisions(revisions) {
if (
typeof revisions !== 'object' ||
revisions === null ||
Array.isArray(revisions) ||
Object.keys(revisions).sort().join(',') !== 'amd64,arm64'
) {
throw new Error('Revisions must contain exactly amd64 and arm64')
}
return Object.fromEntries(
ARCHITECTURES.map((architecture) => [
architecture,
normalizeRevision(revisions[architecture], architecture),
])
)
}
function revisionsFromSnapshot(snapshot) {
return Object.fromEntries(
ARCHITECTURES.flatMap((architecture) => {
const entry = snapshot.entries[architecture]
return entry ? [[architecture, entry.revision]] : []
})
)
}
function snapshotsMatch(left, right) {
return ARCHITECTURES.every(
(architecture) => left[architecture] === right[architecture]
)
}
function resolveSnapcraftChannel(channel, requestedChannel) {
const match = typeof channel === 'string' && CHANNEL_PATTERN.exec(channel)
if (!match?.groups || match[0] !== channel) {
throw new Error(
`Invalid channel "${channel}"; expected <track>/<stable|candidate|beta|edge>`
)
}
const { track, risk } = match.groups
const defaultChannel = track === 'latest' ? risk : channel
if (requestedChannel === undefined) return defaultChannel
if (
requestedChannel !== channel &&
!(track === 'latest' && requestedChannel === risk)
) {
throw new Error(
'Snapcraft channel must be an equivalent spelling of channel'
)
}
return requestedChannel
}
function wait(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
async function fetchChannelSnapshot({
snapName,
channel,
fetchImpl,
attempts,
delayMs,
}) {
const endpoint = `https://api.snapcraft.io/v2/snaps/info/${encodeURIComponent(
snapName
)}`
let lastError
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetchImpl(endpoint, {
headers: {
Accept: 'application/json',
'Snap-Device-Series': '16',
},
signal: AbortSignal.timeout(30_000),
})
if (!response.ok) {
throw new Error(`Snap Store returned HTTP ${response.status}`)
}
return revisionsFromSnapshot(
readStoreChannelSnapshot(await response.json(), {
snapName,
channel,
architectures: ARCHITECTURES,
})
)
} catch (error) {
lastError = error
if (attempt < attempts && delayMs > 0) await wait(delayMs)
}
}
throw new Error(
`Could not read ${snapName} ${channel} after ${attempts} attempt(s): ${
lastError?.message ?? 'unknown error'
}`,
{ cause: lastError }
)
}
async function waitForSnapshot(options, expected) {
let lastObserved
let lastError
for (let attempt = 1; attempt <= options.attempts; attempt += 1) {
try {
lastObserved = await fetchChannelSnapshot({
...options,
attempts: 1,
delayMs: 0,
})
lastError = undefined
if (snapshotsMatch(lastObserved, expected)) return
} catch (error) {
lastError = error
}
if (attempt < options.attempts && options.delayMs > 0) {
await wait(options.delayMs)
}
}
throw new Error(
`Restored channel did not converge: expected ${JSON.stringify(expected)}, ${
lastObserved
? `received ${JSON.stringify(lastObserved)}`
: `last read failed: ${lastError?.message ?? 'unknown error'}`
}`,
lastError ? { cause: lastError } : undefined
)
}
async function defaultRunSnapcraft(snapcraftPath, args, timeoutMs) {
await execFileAsync(snapcraftPath, args, {
killSignal: 'SIGKILL',
maxBuffer: 4 * 1024 * 1024,
timeout: timeoutMs,
})
}
export async function releaseSnapBuildSet({
snapName,
channel,
snapcraftChannel,
version,
revisions,
snapcraftPath = '/snap/bin/snapcraft',
snapcraftCommandTimeoutMs = DEFAULT_SNAPCRAFT_COMMAND_TIMEOUT_MS,
attempts = 12,
delayMs = 10_000,
fetchImpl = globalThis.fetch,
runSnapcraft = (args) =>
defaultRunSnapcraft(snapcraftPath, args, snapcraftCommandTimeoutMs),
}) {
if (!SNAP_NAME_PATTERN.test(snapName)) {
throw new Error(`Invalid Snap name "${snapName}"`)
}
parseStrictSemVer(version, 'Snap version')
if (version.length > 32) throw new Error('Snap version exceeds 32 characters')
if (!path.isAbsolute(snapcraftPath)) {
throw new Error('Snapcraft path must be absolute')
}
if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable')
if (!Number.isSafeInteger(attempts) || attempts < 1 || attempts > 100) {
throw new Error('Attempts must be an integer from 1 to 100')
}
if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > 300_000) {
throw new Error('Delay must be an integer from 0 to 300000 milliseconds')
}
if (
!Number.isSafeInteger(snapcraftCommandTimeoutMs) ||
snapcraftCommandTimeoutMs < 1_000 ||
snapcraftCommandTimeoutMs > 600_000
) {
throw new Error(
'Snapcraft command timeout must be an integer from 1000 to 600000 milliseconds'
)
}
const approved = normalizeRevisions(revisions)
const mutationChannel = resolveSnapcraftChannel(channel, snapcraftChannel)
const snapshotOptions = {
snapName,
channel,
fetchImpl,
attempts,
delayMs,
}
const previous = await fetchChannelSnapshot(snapshotOptions)
try {
await runSnapcraft(['close', snapName, mutationChannel])
for (const architecture of ARCHITECTURES) {
await runSnapcraft([
'release',
snapName,
approved[architecture],
mutationChannel,
])
}
await verifySnapStoreChannel({
snapName,
channel,
version,
architectures: ARCHITECTURES,
expectedRevisions: approved,
attempts,
delayMs,
fetchImpl,
})
} catch (releaseError) {
try {
await runSnapcraft(['close', snapName, mutationChannel])
for (const architecture of ARCHITECTURES) {
if (previous[architecture]) {
await runSnapcraft([
'release',
snapName,
previous[architecture],
mutationChannel,
])
}
}
await waitForSnapshot(snapshotOptions, previous)
} catch (rollbackError) {
let containmentError
try {
await runSnapcraft(['close', snapName, mutationChannel])
} catch (error) {
containmentError = error
}
throw new AggregateError(
[releaseError, rollbackError, containmentError].filter(Boolean),
containmentError
? `Failed to release ${snapName} ${channel}; rollback and containment close both failed`
: `Failed to release ${snapName} ${channel}; rollback failed and the target channel was closed`
)
}
throw new Error(
`Failed to release ${snapName} ${channel}; previous revisions were restored`,
{ cause: releaseError }
)
}
return {
channel,
previous,
released: approved,
version,
}
}
function parsePositiveInteger(value, option) {
if (!/^\d+$/.test(value)) throw new Error(`${option} must be an integer`)
return Number(value)
}
function parseArguments(argv) {
const allowed = new Set([
'--snap',
'--channel',
'--snapcraft-channel',
'--version',
'--amd64-revision',
'--arm64-revision',
'--snapcraft',
'--snapcraft-timeout-ms',
'--attempts',
'--delay-ms',
])
const values = new Map()
for (let index = 0; index < argv.length; index += 2) {
const option = argv[index]
const value = argv[index + 1]
if (!allowed.has(option) || value === undefined || value.startsWith('--')) {
throw new Error(`Invalid argument near "${option ?? ''}"`)
}
if (values.has(option)) throw new Error(`Duplicate argument: ${option}`)
values.set(option, value)
}
return values
}
async function main() {
const args = parseArguments(process.argv.slice(2))
for (const option of [
'--snap',
'--channel',
'--version',
'--amd64-revision',
'--arm64-revision',
]) {
if (!args.has(option))
throw new Error(`Missing required argument: ${option}`)
}
const result = await releaseSnapBuildSet({
snapName: args.get('--snap'),
channel: args.get('--channel'),
snapcraftChannel: args.get('--snapcraft-channel'),
version: args.get('--version'),
revisions: {
amd64: args.get('--amd64-revision'),
arm64: args.get('--arm64-revision'),
},
snapcraftPath: args.get('--snapcraft') ?? '/snap/bin/snapcraft',
snapcraftCommandTimeoutMs: parsePositiveInteger(
args.get('--snapcraft-timeout-ms') ??
String(DEFAULT_SNAPCRAFT_COMMAND_TIMEOUT_MS),
'--snapcraft-timeout-ms'
),
attempts: parsePositiveInteger(
args.get('--attempts') ?? '12',
'--attempts'
),
delayMs: parsePositiveInteger(
args.get('--delay-ms') ?? '10000',
'--delay-ms'
),
})
console.log(
`Released ${args.get('--snap')} ${result.version} to ${result.channel}: ${JSON.stringify(
result.released
)}`
)
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
) {
await main()
}