39.0 KiB · text 806cbc3
import path from 'node:path'
import fs from 'node:fs'
import os from 'node:os'
import { fileURLToPath } from 'node:url'
import * as core from '@actions/core'
import { exec } from '@actions/exec'
import * as tc from '@actions/tool-cache'
import * as semver from 'semver'
import * as csv from 'csv-parse/sync'
import toml from 'smol-toml'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const MAX_HTTP_RETRIES = 3
const APPS = ['erlang', 'elixir', 'gleam', 'rebar']
if (process.env.NODE_ENV !== 'test') {
main().catch((err) => {
core.setFailed(err.message)
})
}
async function main() {
checkOtpArchitecture()
const versionFilePath = getInput('version-file', false)
let versions
if (versionFilePath) {
if (!isStrictVersion()) {
throw new Error(
"you have to set version-type=strict if you're using version-file",
)
}
versions = parseVersionFile(versionFilePath)
}
const otpSpec = getInput('otp-version', true, 'erlang', versions)
const elixirSpec = getInput('elixir-version', false, 'elixir', versions)
const gleamSpec = getInput('gleam-version', false, 'gleam', versions)
const rebar3Spec = getInput('rebar3-version', false, 'rebar', versions)
if (otpSpec !== 'false') {
await installOTP(otpSpec)
const elixirInstalled = await maybeInstallElixir(elixirSpec)
if (elixirInstalled === true) {
const shouldMixRebar = getInput('install-rebar', false)
await mix(shouldMixRebar, 'rebar')
const shouldMixHex = getInput('install-hex', false)
await mix(shouldMixHex, 'hex')
}
} else if (!gleamSpec) {
throw new Error('otp-version=false is only available when installing Gleam')
}
await maybeInstallGleam(gleamSpec)
await maybeInstallRebar3(rebar3Spec)
// undefined is replaced by a function, post- main branch merge
const setupBeamVersion = '149ca49'
core.setOutput('setup-beam-version', setupBeamVersion)
}
async function installOTP(otpSpec) {
const osVersion = getRunnerOSVersion()
const otpVersion = await getOTPVersion(otpSpec, osVersion)
core.startGroup(
`Installing Erlang/OTP ${otpVersion} - built on ${getRunnerOSArchitecture()}/${osVersion}`,
)
await doWithMirrors({
hexMirrors: hexMirrorsInput(),
actionTitle: `install Erlang/OTP ${otpVersion}`,
action: async (hexMirror) => {
await install('otp', {
osVersion,
toolVersion: otpVersion,
hexMirror,
})
},
})
core.setOutput('otp-version', otpVersion)
maybeEnableProblemMatchers('erlang')
core.endGroup()
return otpVersion
}
async function maybeInstallElixir(elixirSpec) {
let installed = false
if (elixirSpec) {
const elixirVersion = await getElixirVersion(elixirSpec)
core.startGroup(`Installing Elixir ${elixirVersion}`)
await doWithMirrors({
hexMirrors: hexMirrorsInput(),
actionTitle: `install Elixir ${elixirVersion}`,
action: async (hexMirror) => {
await install('elixir', {
toolVersion: elixirVersion,
hexMirror,
})
},
})
core.setOutput('elixir-version', elixirVersion)
maybeEnableProblemMatchers('elixir')
core.endGroup()
installed = true
}
return installed
}
function problemMatchersEnabled() {
return getInput('disable_problem_matchers', false) === 'false'
}
// path.join with static strings helps ncc resolve and bundle the JSON files
function maybeEnableProblemMatchers(language) {
if (problemMatchersEnabled()) {
const matcherFiles = {
erlang: path.join(`${__dirname}/../matchers/erlang-matchers.json`),
elixir: path.join(`${__dirname}/../matchers/elixir-matchers.json`),
}
core.info(`##[add-matcher]${matcherFiles[language]}`)
}
}
async function mix(shouldMix, what) {
if (shouldMix === 'true') {
const cmd = 'mix'
const args = [`local.${what}`, '--force']
core.startGroup(`Running ${cmd} ${args}`)
await doWithMirrors({
hexMirrors: hexMirrorsInput(),
actionTitle: `mix ${what}`,
action: async (hexMirror) => {
process.env.HEX_MIRROR = hexMirror
await exec(cmd, args)
},
})
core.endGroup()
}
}
async function maybeInstallGleam(gleamSpec) {
let installed = false
if (gleamSpec) {
const gleamVersion = await getGleamVersion(gleamSpec)
core.startGroup(`Installing Gleam ${gleamVersion}`)
await install('gleam', { toolVersion: gleamVersion })
core.setOutput('gleam-version', gleamVersion)
core.endGroup()
installed = true
}
return installed
}
async function maybeInstallRebar3(rebar3Spec) {
let installed = false
let rebar3Version
if (rebar3Spec) {
if (rebar3Spec === 'nightly') {
rebar3Version = 'nightly'
} else {
rebar3Version = await getRebar3Version(rebar3Spec)
}
core.startGroup(`Installing rebar3 ${rebar3Version}`)
await install('rebar3', { toolVersion: rebar3Version })
core.setOutput('rebar3-version', rebar3Version)
core.endGroup()
installed = true
}
return installed
}
function maybeRemoveOTPPrefix(otpSpec) {
return otpSpec.replace(/^OTP-/, '')
}
async function getOTPVersion(otpSpec0, osVersion) {
const [otpVersions, originListing, hexMirrors] =
await getOTPVersions(osVersion)
let spec = maybeRemoveOTPPrefix(otpSpec0)
const versions = otpVersions
const otpVersion = getVersionFromSpec(spec, versions)
if (otpVersion === null) {
throw new Error(
requestedVersionFor('Erlang/OTP', otpSpec0, originListing, hexMirrors),
)
}
return otpVersion // from the reference, for download
}
function requestedVersionFor(tool, version, originListing, mirrors) {
const isVersionTypeStrict = isStrictVersion()
let versionType = 'loose'
if (isVersionTypeStrict) {
versionType = 'strict'
}
let ret =
`Requested ${versionType} ${tool} version (${version}) not found in version list, ` +
`at ${originListing}${mirrors ? `, with mirrors ${mirrors}` : ''}.`
if (!isVersionTypeStrict) {
ret = `${ret} Should you be using option 'version-type': 'strict'?`
}
return ret
}
const knownBranches = ['main', 'master', 'maint']
const nonSpecificVersions = ['nightly', 'latest']
async function getElixirVersion(exSpec0) {
const otpSuffix = /-otp-(\d+)/
const userSuppliedOtp = exSpec0.match(otpSuffix)?.[1] ?? null
let otpVersionMajor = ''
if (userSuppliedOtp && isVersion(userSuppliedOtp)) {
otpVersionMajor = userSuppliedOtp
} else {
let cmd = 'erl'
if (process.platform === 'win32') {
cmd = 'erl.exe'
}
const args = [
'-noshell',
'-eval',
'io:format(erlang:system_info(otp_release)), halt().',
]
await exec(cmd, args, {
listeners: {
stdout: (data) => {
otpVersionMajor = data.toString()
},
},
})
}
const [otpVersionsForElixirMap, elixirVersions, originListing, hexMirrors] =
await getElixirVersions()
const spec = exSpec0.replace(otpSuffix, '')
const versions = elixirVersions
const elixirVersionFromSpec = getVersionFromSpec(spec, versions)
if (elixirVersionFromSpec === null) {
throw new Error(
requestedVersionFor('Elixir', exSpec0, originListing, hexMirrors),
)
}
let foundCombo = false
let otpVersionMajorIter = parseInt(otpVersionMajor)
let otpVersionsMajor = []
while (otpVersionMajorIter > otpVersionMajor - 3) {
otpVersionMajorIter += ''
otpVersionsMajor.push(otpVersionMajorIter)
const elixirVersionComp = otpVersionsForElixirMap[elixirVersionFromSpec]
if (
(elixirVersionComp && elixirVersionComp.includes(otpVersionMajorIter)) ||
!isVersion(otpVersionMajorIter)
) {
core.info(
`Using Elixir ${elixirVersionFromSpec} (built for Erlang/OTP ${otpVersionMajorIter})`,
)
foundCombo = true
break
}
otpVersionMajorIter = parseInt(otpVersionMajorIter) - 1
}
if (!foundCombo) {
throw new Error(
`Requested Elixir / Erlang/OTP version (${exSpec0} / tried ${otpVersionsMajor}) not ` +
'found in version list (did you check Compatibility between Elixir and Erlang/OTP?). ' +
'Elixir and Erlang/OTP compatibility can be found at: ' +
'https://hexdocs.pm/elixir/compatibility-and-deprecations.html',
)
}
let elixirVersionForDownload = elixirVersionFromSpec
if (isVersion(otpVersionMajorIter)) {
elixirVersionForDownload = `${elixirVersionFromSpec}-otp-${otpVersionMajorIter}`
}
return maybePrependWithV(elixirVersionForDownload)
}
async function getGleamVersion(gleamSpec0) {
const [gleamVersions, originListing] = await getGleamVersions()
const spec = gleamSpec0
const versions = gleamVersions
const gleamVersion = getVersionFromSpec(spec, versions)
if (gleamVersion === null) {
throw new Error(requestedVersionFor('Gleam', gleamSpec0, originListing))
}
return maybePrependWithV(gleamVersion)
}
async function getRebar3Version(r3Spec) {
const [rebar3Versions, originListing] = await getRebar3Versions()
const spec = r3Spec
const versions = rebar3Versions
const rebar3Version = getVersionFromSpec(spec, versions)
if (rebar3Version === null) {
throw new Error(requestedVersionFor('rebar3', r3Spec, originListing))
}
return rebar3Version
}
function otpArchitecture() {
return getInput('otp-architecture', false)
}
async function getOTPVersions(osVersion) {
let otpVersionsListings
let originListing
let hexMirrors = null
if (process.platform === 'linux') {
originListing = `/builds/otp/${getRunnerOSArchitecture()}/${osVersion}/builds.txt`
hexMirrors = hexMirrorsInput()
otpVersionsListings = await doWithMirrors({
hexMirrors,
actionTitle: `fetch ${originListing}`,
action: async (hexMirror) => {
return get(`${hexMirror}${originListing}`)
},
})
} else if (process.platform === 'win32') {
originListing =
'https://api.github.com/repos/erlang/otp/releases?per_page=100'
otpVersionsListings = await get(originListing, [1, 2, 3])
} else if (process.platform === 'darwin') {
const arch = getRunnerOSArchitecture()
let targetArch
switch (arch) {
case 'amd64':
targetArch = 'x86_64'
break
case 'arm64':
targetArch = 'aarch64'
break
}
originListing =
`https://raw.githubusercontent.com/erlef/otp_builds/refs/heads/main` +
`/builds/${targetArch}-apple-darwin.csv`
otpVersionsListings = await get(originListing)
}
debugLog(
`OTP versions listings from ${originListing}, mirrors ${hexMirrors}`,
otpVersionsListings,
)
const otpVersions = {}
if (process.platform === 'linux') {
otpVersionsListings
.trim()
.split('\n')
.forEach((line) => {
const otpVersionOrig = line.match(/^([^ ]+)?( .+)/)[1]
const otpVersion = maybeRemoveOTPPrefix(otpVersionOrig)
debugLog('OTP line and parsing', [line, otpVersion, otpVersionOrig])
otpVersions[otpVersion] = otpVersionOrig // we keep the original for later reference
})
} else if (process.platform === 'win32') {
const otpArch = otpArchitecture()
const file_regex = new RegExp(`^otp_win${RegExp.escape(otpArch)}_(.*).exe$`)
otpVersionsListings.forEach((otpVersionsListing) => {
otpVersionsListing
.map((x) => x.assets)
.flat()
.filter((x) => x.name.match(file_regex))
.forEach((x) => {
const otpVersionOrig = x.name.match(file_regex)[1]
const otpVersion = otpVersionOrig
debugLog('OTP line and parsing', [x.name, otpVersion, otpVersionOrig])
otpVersions[otpVersion] = otpVersionOrig
})
})
} else if (process.platform === 'darwin') {
csv
.parse(otpVersionsListings, {
columns: true,
})
.forEach((line) => {
const otpVersionOrig = line.ref_name
const otpVersion = maybeRemoveOTPPrefix(otpVersionOrig)
debugLog('OTP line and parsing', [line, otpVersion, otpVersionOrig])
otpVersions[otpVersion] = otpVersionOrig // we keep the original for later reference
})
}
debugLog(
`OTP versions from ${originListing}, mirrors ${hexMirrors}`,
JSON.stringify(otpVersions),
)
return [otpVersions, originListing, hexMirrors]
}
async function getElixirVersions() {
const originListing = '/builds/elixir/builds.txt'
const hexMirrors = hexMirrorsInput()
const elixirVersionsListings = await doWithMirrors({
hexMirrors,
actionTitle: `fetch ${originListing}`,
action: async (hexMirror) => {
return get(`${hexMirror}${originListing}`)
},
})
const otpVersionsForElixirMap = {}
const elixirVersions = {}
elixirVersionsListings
.trim()
.split('\n')
.forEach((line) => {
const elixirMatch =
line.match(/^v?(.+)-otp-([^ ]+)/) || line.match(/^v?([^ ]+)/)
const elixirVersion = elixirMatch[1]
const otpVersion = elixirMatch[2]
const otpVersions = otpVersionsForElixirMap[elixirVersion] || []
if (otpVersion) {
// -otp- present (special case)
otpVersions.push(otpVersion)
}
otpVersionsForElixirMap[elixirVersion] = otpVersions
elixirVersions[elixirVersion] = elixirVersion
})
return [otpVersionsForElixirMap, elixirVersions, originListing, hexMirrors]
}
async function getGleamVersions() {
const originListing =
'https://api.github.com/repos/gleam-lang/gleam/releases?per_page=100'
const resultJSONs = await get(originListing, [1, 2, 3])
const gleamVersionsListing = {}
resultJSONs.forEach((resultJSON) => {
resultJSON
.map((x) => x.tag_name)
.forEach((ver) => {
const gleamMatch = ver.match(/^v?([^ ]+)/)
const gleamVersion = gleamMatch[1]
gleamVersionsListing[gleamVersion] = gleamVersion
})
})
return [gleamVersionsListing, originListing]
}
async function getRebar3Versions() {
const originListing =
'https://api.github.com/repos/erlang/rebar3/releases?per_page=100'
const resultJSONs = await get(originListing, [1, 2, 3])
const rebar3VersionsListing = {}
resultJSONs.forEach((resultJSON) => {
resultJSON
.map((x) => x.tag_name)
.forEach((ver) => {
rebar3VersionsListing[ver] = ver
})
})
return [rebar3VersionsListing, originListing]
}
function isStrictVersion() {
return getInput('version-type', false) === 'strict'
}
function gt(left, right) {
return semver.gt(parseVersion(left), parseVersion(right))
}
function validVersion(v) {
return (
v.match(
new RegExp(`${knownBranches.join('|')}|${nonSpecificVersions.join('|')}`),
) == null &&
// these ones are for rebar3, which has alpha and beta releases
!v.startsWith('a') &&
!v.startsWith('b')
)
}
function parseVersion(v) {
v = v.includes('rc') ? v : v.split('.')
if (v instanceof Array) {
v = `${[v.shift(), v.shift(), v.shift()].join('.')}+${v.join('.')}`
}
return semver.coerce(v, { includePrerelease: true, loose: true })
}
function getVersionFromSpec(spec0, versions0) {
let latest
Object.keys(versions0).forEach((v) => {
if (validVersion(v)) {
latest = latest && gt(latest, v) ? latest : v
}
})
versions0.latest = latest
const spec = maybeRemoveVPrefix(spec0)
const altVersions = {}
Object.entries(versions0).forEach(([version, altVersion]) => {
let coerced
if (
isStrictVersion() ||
isRC(version) ||
isKnownBranch(version) ||
isKnownVerBranch(version)
) {
// If `version-type: strict`, version is an RC, or version is "a branch"
// we just try to remove a potential initial v
coerced = maybeRemoveVPrefix(version)
} else {
// Otherwise, we place the version into a version bucket
coerced = maybeCoerced(version)
}
const alt = (altVersions[coerced] || []).concat(altVersion)
alt.sort(sortVersions)
altVersions[coerced] = alt
})
let versions = Object.keys(altVersions)
const rangeForMax = semver.validRange(spec0) || maybeCoerced(spec)
const rangeMax = semver.maxSatisfying(versions, rangeForMax)
let version = null
if (spec0 === 'latest') {
version = versions0[versions0.latest]
} else if (
isStrictVersion() ||
isRC(spec0) ||
isKnownBranch(spec0) ||
isKnownVerBranch(spec0) ||
spec0 === 'nightly'
) {
if (versions0[spec]) {
// We obtain it directly
version = versions0[spec]
}
} else if (rangeMax !== null) {
// Otherwise, we compare alt. versions' semver ranges to this version, from highest to lowest
const thatVersion = spec
const thatVersionAbc = versionAbc(thatVersion)
const thatVersionAbcRange = semver.validRange(thatVersionAbc)
versions = altVersions[rangeMax]
for (let i = versions.length - 1; i >= 0; i -= 1) {
const thisVersion = versions[i]
const thisVersionAbc = versionAbc(thisVersion)
const thisVersionAbcRange = semver.validRange(thisVersionAbc)
if (
thatVersionAbcRange &&
semver.intersects(thatVersionAbcRange, thisVersionAbcRange)
) {
version = thisVersion
break
}
}
}
return version || null
}
function maybeCoerced(v) {
let ret
try {
if (!isRC(v)) {
ret = semver.coerce(v).version
} else {
ret = maybeRemoveVPrefix(v)
}
} catch {
// some stuff can't be coerced, like 'main'
core.debug(`Was not able to coerce ${v} with semver`)
ret = v
}
return ret
}
function sortVersions(left, right) {
let ret = 0
const newL = verAsComparableStr(left)
const newR = verAsComparableStr(right)
function verAsComparableStr(ver) {
const matchGroups = 6
const verSpec = xyzAbcVersion('', '')
const matches = ver.match(verSpec).splice(1, matchGroups)
return matches.reduce((acc, v) => acc + (v || '0').padStart(3, '0'), '')
}
if (newL < newR) {
ret = -1
} else if (newL > newR) {
ret = 1
}
return ret
}
function isRC(ver) {
return ver.match(xyzAbcVersion('^', '(?:-rc\\.?\\d+)'))
}
function isKnownBranch(ver) {
return knownBranches.includes(ver)
}
function isKnownVerBranch(ver) {
return knownBranches.some((b) => ver.match(b))
}
function githubARMRunnerArchs() {
return ['ARM', 'ARM64']
}
function githubAMDRunnerArchs() {
return ['X86', 'X64']
}
function getRunnerOSArchitecture() {
// These options come from:
// https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables
if (githubARMRunnerArchs().includes(process.env.RUNNER_ARCH)) {
return 'arm64'
}
if (githubAMDRunnerArchs().includes(process.env.RUNNER_ARCH)) {
return 'amd64'
}
throw new Error(
'Invalid Github runner architecture, expected one of ' +
`${githubAMDRunnerArchs().concat(githubARMRunnerArchs()).join(', ')} ` +
`but got process.env.RUNNER_ARCH = ${process.env.RUNNER_ARCH}`,
)
}
function getRunnerOSVersion() {
// List from https://github.com/actions/runner-images?tab=readme-ov-file#available-images
// at e065a395d52f491c58eb8fba8ab1dde2c7cfada1
// Not considered, at the moment (though it might be easy to adapt to...):
// - ubuntu-slim
// - all macOS intel, large, and xlarge variants
// - all win arm variants
const ImageOSToContainer = {
ubuntu22: 'ubuntu-22.04',
'ubuntu22-arm64': 'ubuntu-22.04',
ubuntu24: 'ubuntu-24.04',
'ubuntu24-arm64': 'ubuntu-24.04',
ubuntu26: 'ubuntu-26.04',
'ubuntu26-arm64': 'ubuntu-26.04',
win22: 'windows-2022',
win25: 'windows-2025',
'win25-vs2026': 'windows-2025',
macos14: 'macOS-14',
macos15: 'macOS-15',
macos26: 'macOS-26',
}
const deprecatedImageOSToContainer = {
ubuntu18: 'ubuntu-18.04',
ubuntu20: 'ubuntu-20.04',
win19: 'windows-2019',
macos13: 'macOS-13',
}
const containerFromEnvImageOS = ImageOSToContainer[process.env.ImageOS]
if (!containerFromEnvImageOS) {
const deprecatedContainerFromEnvImageOS =
deprecatedImageOSToContainer[process.env.ImageOS]
if (deprecatedContainerFromEnvImageOS) {
core.warning(
`You are using deprecated ImageOS ${deprecatedContainerFromEnvImageOS}. ` +
'Support for maintenance is very limited. Consider a non-deprecated version as ' +
'mentioned in the README.md.',
)
return deprecatedContainerFromEnvImageOS
} else {
// neiam fork: self-hosted forgejo/act runners derive ImageOS from the
// runs-on label (e.g. 'arcrunner-set') and overwrite any env we set,
// which upstream treats as fatal. Our runner's catthehacker act image
// is Ubuntu 24.04, so fall back to that instead of erroring. A valid
// ImageOS (mapped above) is still honoured.
core.warning(
`Unmappable ImageOS '${process.env.ImageOS}' on a self-hosted runner; ` +
'defaulting to ubuntu-24.04. Set a mappable env.ImageOS to override.',
)
return 'ubuntu-24.04'
}
}
return containerFromEnvImageOS
}
async function getUrlResponse(url, headers, attempt = 1) {
try {
const response = await fetch(url, {
headers,
signal: AbortSignal.timeout(10000),
})
const contentType = response.headers.get('content-type') || ''
if (!response.ok) {
throw new Error(response.statusText)
}
if (contentType.indexOf('application/json') !== -1) {
return response.json()
} else {
return response.text()
}
} catch (err) {
core.debug(`Error fetching from ${url}: ${err}`)
if (attempt <= MAX_HTTP_RETRIES) {
const delay = attempt * 2 * 1000
core.debug(`Error during fetch. Retrying in ${delay}ms`)
await new Promise((resolve) => setTimeout(resolve, delay))
return getUrlResponse(url, headers, attempt + 1)
} else {
throw err
}
}
}
async function get(url0, pageIdxs) {
const url = new URL(url0)
const headers = {}
const GithubToken = getInput('github-token', false)
if (GithubToken && url.host === 'api.github.com') {
headers.authorization = `Bearer ${GithubToken}`
}
if ((pageIdxs || []).length === 0) {
return getUrlResponse(url, headers)
} else {
return Promise.all(
pageIdxs.map((page) => {
const urlWithPage = new URL(url)
urlWithPage.searchParams.append('page', page)
return getUrlResponse(urlWithPage, headers)
}),
)
}
}
function maybePrependWithV(v) {
if (isVersion(v)) {
return `v${v.replace('v', '')}`
}
return v
}
function maybeRemoveVPrefix(ver) {
let ret = ver
if (isVersion(ver)) {
ret = ver.replace('v', '')
}
return ret
}
function xyzAbcVersion(pref, suf) {
// This accounts for stuff like 6.0.2.0.1.0, as proposed by Erlang's
// https://www.erlang.org/doc/system_principles/versions.html
const dd = '\\.?(\\d+)?'
return new RegExp(
`${pref}(?:OTP-)?v?(\\d+)${dd}${dd}${dd}${dd}${dd}(?:-rc\\.?\\d+)?(?:-otp-\\d+)?${suf}`,
)
}
function versionAbc(ver) {
// For a version like 6.0.2.0.1.0, return 0.1.0
return ver.match(/\d+(?:\.[^.]+)?(?:\.[^.]+)?(?:\.)?(.*)/)[1]
}
function isVersion(v) {
return v.match(xyzAbcVersion('^', '$')) !== null
}
function getInput(inputName, required, alternativeName, alternatives) {
const alternativeValue = (alternatives || new Map()).get(alternativeName)
let input = core.getInput(inputName, {
required: alternativeValue ? false : required,
})
// We can't have both input and alternativeValue set
if (input && alternativeValue) {
throw new Error(
`Found input ${inputName}=${input} (from the YML) \
alongside ${alternativeName}=${alternativeValue} \
(from the version file). You must choose one or the other.`,
)
} else if (!input) {
input = alternativeValue
}
return input
}
function parseToolVersionsFile(versionFilePath) {
const appVersions = new Map()
const versions = fs.readFileSync(versionFilePath, 'utf8')
versions.split(/\r?\n/).forEach((line) => {
const appVersion = line.match(/^([^ ]+)[ ]+(ref:v?)?([^ #]+)/)
if (appVersion) {
const app = appVersion[1]
if (APPS.includes(app)) {
const [, , , version] = appVersion
core.info(`Consuming ${app} at version ${version}`)
appVersions.set(app, version)
}
}
})
if (!appVersions.size) {
core.info('There was apparently nothing to consume')
} else {
core.info('... done!')
}
core.endGroup()
return appVersions
}
function parseMiseTomlFile(versionFilePath) {
const appVersions = new Map()
const miseToml = fs.readFileSync(versionFilePath, 'utf8')
const miseTomlParsed = toml.parse(miseToml)
for (const app in miseTomlParsed.tools) {
if (APPS.includes(app)) {
const appVersion = miseTomlParsed.tools[app]
const version =
typeof appVersion == 'object' ? appVersion.version : appVersion
core.info(`Consuming ${app} at version ${version}`)
appVersions.set(app, version)
}
}
return appVersions
}
function parseVersionFile(versionFilePath0) {
const versionFilePath = path.resolve(
process.env.GITHUB_WORKSPACE,
versionFilePath0,
)
if (!fs.existsSync(versionFilePath)) {
throw new Error(
`The specified version file, ${versionFilePath0}, does not exist`,
)
}
if (versionFilePath0.endsWith('.toml')) {
core.startGroup(
`Parsing ${path.basename(versionFilePath0)} file at ${versionFilePath0}`,
)
return parseMiseTomlFile(versionFilePath)
}
core.startGroup(`Parsing .tool-versions file at ${versionFilePath0}`)
return parseToolVersionsFile(versionFilePath)
}
function debugLog(groupName, message) {
const group = `Debugging for ${groupName}`
core.debug(
'┌──────────────────────────────────────────────────────────────────────────',
)
core.debug(`│ ${group} - start`)
core.debug(message)
core.debug(`│ ${group} - stop`)
core.debug(
'└──────────────────────────────────────────────────────────────────────────',
)
}
function hexMirrorsInput() {
return core.getMultilineInput('hexpm-mirrors', {
required: false,
})
}
async function doWithMirrors(opts) {
const { hexMirrors, actionTitle, action } = opts
let actionRes
if (hexMirrors.length === 0) {
throw new Error(`Could not ${actionTitle} from any hex.pm mirror`)
}
const [hexMirror, ...hexMirrorsT] = hexMirrors
try {
actionRes = await action(hexMirror)
} catch (err) {
core.info(
`Action ${actionTitle} failed for mirror ${hexMirror}, with ${err}`,
)
core.debug(`Stacktrace: ${err.stack}`)
actionRes = await doWithMirrors({
hexMirrors: hexMirrorsT,
actionTitle,
action,
})
}
return actionRes
}
async function install(toolName, opts) {
const { osVersion, toolVersion, hexMirror } = opts
const versionSpec =
osVersion !== undefined ? `${osVersion}/${toolVersion}` : toolVersion
let installOpts
// The installOpts object is composed of supported processPlatform keys
// (e.g. 'linux', 'win32', or 'all' - in case there's no distinction between platforms)
// In each of these keys there's an object with keys:
// * downloadToolURL
// - where to fetch the downloadable from
// * extract
// - if the downloadable is compressed: how to extract it
// - return ['dir', targetDir]
// - if the downloadable is not compressed: a filename.ext you want to cache it under
// - return ['file', filenameWithExt]
// * postExtract
// - stuff to execute outside the cache scope (just after it's created)
// * reportVersion
/// - configuration elements on how to output the tool version, post-install
switch (toolName) {
case 'otp':
installOpts = {
tool: 'Erlang/OTP',
linux: {
downloadToolURL: () =>
`${hexMirror}/builds/otp/${getRunnerOSArchitecture()}/${versionSpec}.tar.gz`,
extract: async (file) => {
const dest = undefined
const flags = ['zx', '--strip-components=1']
const targetDir = await tc.extractTar(file, dest, flags)
return ['dir', targetDir]
},
postExtract: async (cachePath) => {
const cmd = path.join(cachePath, 'Install')
const args = ['-minimal', cachePath]
await exec(cmd, args)
},
reportVersion: () => {
const cmd = 'erl'
const args = ['-version']
const env = {}
return [cmd, args, env]
},
},
win32: {
downloadToolURL: () => {
const otpArch = otpArchitecture()
return (
'https://github.com/erlang/otp/releases/download/' +
`OTP-${toolVersion}/otp_win${otpArch}_${toolVersion}.exe`
)
},
extract: async () => ['file', 'otp.exe'],
postExtract: async (cachePath) => {
const cmd = path.join(cachePath, 'otp.exe')
const args = ['/S', `/D=${cachePath}`]
await exec(cmd, args)
},
reportVersion: () => {
const cmd = 'erl.exe'
const args = ['+V']
const env = {}
return [cmd, args, env]
},
},
darwin: {
downloadToolURL: (versionSpec) => {
let suffix = ''
if (isKnownVerBranch(versionSpec)) {
// for these otp_builds adds `-latest` in the folder path
suffix = '-latest'
}
return (
`https://github.com/erlef/otp_builds/releases/download/` +
`${toolVersion}${suffix}/${toolVersion}-macos-${getRunnerOSArchitecture()}.tar.gz`
)
},
extract: async (file) => {
const dest = undefined
const flags = ['zx']
const targetDir = await tc.extractTar(file, dest, flags)
return ['dir', targetDir]
},
postExtract: async (/*cachePath*/) => {
// nothing to do
},
reportVersion: () => {
const cmd = 'erl'
const args = ['-version']
const env = {}
return [cmd, args, env]
},
},
}
break
case 'elixir':
installOpts = {
tool: 'Elixir',
all: {
downloadToolURL: () =>
`${hexMirror}/builds/elixir/${versionSpec}.zip`,
extract: async (file) => {
const targetDir = await tc.extractZip(file)
return ['dir', targetDir]
},
postExtract: async () => {
const escriptsPath = path.join(os.homedir(), '.mix', 'escripts')
fs.mkdirSync(escriptsPath, { recursive: true })
core.addPath(escriptsPath)
if (debugLoggingEnabled()) {
core.exportVariable('ELIXIR_CLI_ECHO', 'true')
}
},
reportVersion: () => {
const cmd = 'elixir'
const args = ['-v']
const env = {}
return [cmd, args, env]
},
},
}
break
case 'gleam':
installOpts = {
tool: 'Gleam',
linux: {
downloadToolURL: () => {
const arch = getRunnerOSArchitecture()
let gz
if (
versionSpec === 'nightly' ||
semver.gt(versionSpec, 'v0.22.1')
) {
const target = arch === 'arm64' ? 'aarch64' : 'x86_64'
gz = `gleam-${versionSpec}-${target}-unknown-linux-musl.tar.gz`
} else {
gz = `gleam-${versionSpec}-linux-amd64.tar.gz`
}
return `https://github.com/gleam-lang/gleam/releases/download/${versionSpec}/${gz}`
},
extract: async (file) => {
const dest = undefined
const flags = ['zx']
const targetDir = await tc.extractTar(file, dest, flags)
return ['dir', targetDir]
},
postExtract: async (cachePath) => {
const bindir = path.join(cachePath, 'bin')
const oldPath = path.join(cachePath, 'gleam')
const newPath = path.join(bindir, 'gleam')
fs.mkdirSync(bindir)
fs.renameSync(oldPath, newPath)
},
reportVersion: () => {
const cmd = 'gleam'
const args = ['--version']
const env = {}
return [cmd, args, env]
},
},
win32: {
downloadToolURL: () => {
let zip
if (
versionSpec === 'nightly' ||
semver.gt(versionSpec, 'v0.22.1')
) {
zip = `gleam-${versionSpec}-x86_64-pc-windows-msvc.zip`
} else {
zip = `gleam-${versionSpec}-windows-64bit.zip`
}
return `https://github.com/gleam-lang/gleam/releases/download/${versionSpec}/${zip}`
},
extract: async (file) => {
const targetDir = await tc.extractZip(file)
return ['dir', targetDir]
},
postExtract: async (cachePath) => {
const bindir = path.join(cachePath, 'bin')
const oldPath = path.join(cachePath, 'gleam.exe')
const newPath = path.join(bindir, 'gleam.exe')
fs.mkdirSync(bindir)
fs.renameSync(oldPath, newPath)
},
reportVersion: () => {
const cmd = 'gleam.exe'
const args = ['--version']
const env = {}
return [cmd, args, env]
},
},
}
installOpts.darwin = {
downloadToolURL: () => {
const arch = getRunnerOSArchitecture()
let gz
if (versionSpec === 'nightly' || semver.gt(versionSpec, 'v0.22.1')) {
const target = arch === 'arm64' ? 'aarch64' : 'x86_64'
gz = `gleam-${versionSpec}-${target}-apple-darwin.tar.gz`
} else {
gz =
arch === 'arm64'
? `gleam-${versionSpec}-macos-arm64.tar.gz`
: `gleam-${versionSpec}-macos.tar.gz`
}
return `https://github.com/gleam-lang/gleam/releases/download/${versionSpec}/${gz}`
},
extract: installOpts.linux.extract,
postExtract: installOpts.linux.postExtract,
reportVersion: installOpts.linux.reportVersion,
}
break
case 'rebar3':
installOpts = {
tool: 'Rebar3',
linux: {
downloadToolURL: () => {
let url
if (versionSpec === 'nightly') {
url = 'https://s3.amazonaws.com/rebar3-nightly/rebar3'
} else {
url = `https://github.com/erlang/rebar3/releases/download/${versionSpec}/rebar3`
}
return url
},
extract: async () => ['file', 'rebar3'],
postExtract: async (cachePath) => {
const bindir = path.join(cachePath, 'bin')
const oldPath = path.join(cachePath, 'rebar3')
const newPath = path.join(bindir, 'rebar3')
fs.mkdirSync(bindir, { recursive: true })
fs.renameSync(oldPath, newPath)
fs.chmodSync(newPath, 0o755)
},
reportVersion: () => {
const cmd = 'rebar3'
const args = ['version']
const env = {
REBAR_GLOBAL_CONFIG_DIR: '/fake-dir',
REBAR_CONFIG: 'fake.config',
}
return [cmd, args, env]
},
},
win32: {
downloadToolURL: () => {
let url
if (versionSpec === 'nightly') {
url = 'https://s3.amazonaws.com/rebar3-nightly/rebar3'
} else {
url = `https://github.com/erlang/rebar3/releases/download/${versionSpec}/rebar3`
}
return url
},
extract: async () => ['file', 'rebar3'],
postExtract: async (cachePath) => {
const bindir = path.join(cachePath, 'bin')
const oldPath = path.join(cachePath, 'rebar3')
const newPath = path.join(bindir, 'rebar3')
fs.mkdirSync(bindir)
fs.renameSync(oldPath, newPath)
fs.chmodSync(newPath, 0o755)
const ps1Filename = path.join(bindir, 'rebar3.ps1')
fs.writeFileSync(ps1Filename, `& escript.exe ${newPath} \${args}`)
const cmdFilename = path.join(bindir, 'rebar3.cmd')
fs.writeFileSync(
cmdFilename,
`@echo off\r\nescript.exe ${newPath} %*`,
)
},
reportVersion: () => {
const cmd = 'rebar3.cmd'
const args = ['version']
const env = {}
return [cmd, args, env]
},
},
}
installOpts.darwin = installOpts.linux
break
default:
throw new Error(`no installer for ${toolName}`)
}
await installTool({ toolName, versionSpec, installOpts })
}
async function installTool(opts) {
const { toolName, versionSpec, installOpts } = opts
const platformOpts = installOpts[process.platform] || installOpts.all
let cachePath = tc.find(toolName, versionSpec)
core.debug(`Checking if ${installOpts.tool} is already cached...`)
if (cachePath === '') {
core.debug(" ... it isn't!")
const downloadToolURL = platformOpts.downloadToolURL(versionSpec)
const file = await tc.downloadTool(downloadToolURL)
const [targetElemType, targetElem] = await platformOpts.extract(file)
if (targetElemType === 'dir') {
cachePath = await tc.cacheDir(targetElem, toolName, versionSpec)
} else if (targetElemType === 'file') {
cachePath = await tc.cacheFile(file, targetElem, toolName, versionSpec)
}
} else {
core.debug(` ... it is, at ${cachePath}`)
}
// This makes sure we run, e.g. in Windows, the installer in the runner
// We're not caching the install, just the downloaded tool
const runnerToolPath = path.join(
process.env.RUNNER_TEMP,
'.setup-beam',
toolName,
)
fs.cpSync(cachePath, runnerToolPath, { recursive: true })
core.debug('Performing post extract operations...')
await platformOpts.postExtract(runnerToolPath)
core.debug(`Adding ${runnerToolPath}' bin to system path`)
const runnerToolPathBin = path.join(runnerToolPath, 'bin')
core.addPath(runnerToolPathBin)
const installDirForVarName = `INSTALL_DIR_FOR_${toolName}`.toUpperCase()
core.debug(`Exporting ${installDirForVarName} as ${runnerToolPath}`)
core.exportVariable(installDirForVarName, runnerToolPath)
core.info(`Installed ${installOpts.tool} version`)
const [cmd, args, env] = platformOpts.reportVersion()
await exec(cmd, args, { env: { ...process.env, ...env } })
}
function checkOtpArchitecture() {
const otpArch = otpArchitecture()
if (process.platform !== 'win32' && otpArch == '32') {
throw new Error(
'@erlef/setup-beam only supports otp-architecture=32 on Windows',
)
}
if (!['32', '64'].includes(otpArch)) {
throw new Error('otp-architecture must be 32 or 64')
}
}
function debugLoggingEnabled() {
return !!process.env.RUNNER_DEBUG
}
export default {
get,
getElixirVersion,
getGleamVersion,
getOTPVersion,
getRebar3Version,
getRunnerOSVersion,
getVersionFromSpec,
githubAMDRunnerArchs,
githubARMRunnerArchs,
install,
installOTP,
maybeInstallElixir,
maybeInstallGleam,
maybeInstallRebar3,
parseVersionFile,
}