Implement fallback install and outputs (#2)
* Add better version support. Restructure code. Implement OS agnostic fallback * Support latest for cabal and stack. Add enable-stack option. * Add outputs * Update documentation. Change default for GHC to latest.
This commit is contained in:
311
src/installer.ts
311
src/installer.ts
@@ -1,181 +1,190 @@
|
||||
import * as core from '@actions/core';
|
||||
import {exec} from '@actions/exec';
|
||||
import * as io from '@actions/io';
|
||||
import {which} from '@actions/io';
|
||||
import {create as glob} from '@actions/glob';
|
||||
import * as tc from '@actions/tool-cache';
|
||||
import {promises as fs, readFileSync} from 'fs';
|
||||
import {safeLoad} from 'js-yaml';
|
||||
import {promises as fs} from 'fs';
|
||||
import {join} from 'path';
|
||||
import type {OS, Tool} from './opts';
|
||||
|
||||
export interface ProgramOpt {
|
||||
enable: boolean;
|
||||
version: string;
|
||||
install: (version: string) => Promise<void>;
|
||||
function failed(tool: Tool, version: string): void {
|
||||
throw new Error(`All install methods for ${tool} ${version} failed`);
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
ghc: ProgramOpt;
|
||||
cabal: ProgramOpt;
|
||||
stack: ProgramOpt & {setup: boolean};
|
||||
}
|
||||
|
||||
export interface Defaults {
|
||||
ghc: {version: string};
|
||||
cabal: {version: string};
|
||||
}
|
||||
|
||||
export function getDefaults(): Defaults {
|
||||
const actionYml = safeLoad(
|
||||
readFileSync(join(__dirname, '..', 'action.yml'), 'utf8')
|
||||
async function success(
|
||||
tool: Tool,
|
||||
version: string,
|
||||
path: string
|
||||
): Promise<true> {
|
||||
core.addPath(path);
|
||||
core.setOutput(`${tool}-path`, path);
|
||||
core.setOutput(`${tool}-exe`, await which(tool));
|
||||
core.info(
|
||||
`Found ${tool} ${version} in cache at path ${path}. Setup successful.`
|
||||
);
|
||||
return {
|
||||
ghc: {version: actionYml.inputs['ghc-version'].default},
|
||||
cabal: {version: actionYml.inputs['cabal-version'].default}
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getOpts(def: Defaults): Options {
|
||||
const stackNoGlobal = core.getInput('stack-no-global') !== '';
|
||||
const stackVersion = core.getInput('stack-version');
|
||||
const stackSetupGhc = core.getInput('stack-setup-ghc') !== '';
|
||||
function warn(tool: Tool, version: string): void {
|
||||
const policy = {
|
||||
cabal: `the two latest major releases of ${tool} are commonly supported.`,
|
||||
ghc: `the three latest major releases of ${tool} are commonly supported.`,
|
||||
stack: `the latest release of ${tool} is commonly supported.`
|
||||
}[tool];
|
||||
|
||||
const errors = [];
|
||||
if (stackNoGlobal && stackVersion === '') {
|
||||
errors.push('stack-version is required if stack-no-global is set');
|
||||
}
|
||||
core.warning(
|
||||
`${tool} ${version} was not found in the cache. It will be downloaded.\n` +
|
||||
`If this is unexpected, please check if version ${version} is pre-installed.\n` +
|
||||
`The list of pre-installed versions is available here: https://help.github.com/en/actions/reference/software-installed-on-github-hosted-runners\n` +
|
||||
`The above list follows a common haskell convention that ${policy}\n` +
|
||||
'If the list is outdated, please file an issue here: https://github.com/actions/virtual-environments\n' +
|
||||
'by using the appropriate tool request template: https://github.com/actions/virtual-environments/issues/new/choose'
|
||||
);
|
||||
}
|
||||
|
||||
if (stackSetupGhc && stackVersion === '') {
|
||||
errors.push('stack-version is required if stack-setup-ghc is set');
|
||||
}
|
||||
async function isInstalled(
|
||||
tool: Tool,
|
||||
version: string,
|
||||
os: OS
|
||||
): Promise<boolean> {
|
||||
const toolPath = tc.find(tool, version);
|
||||
if (toolPath) return success(tool, version, toolPath);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join('\n'));
|
||||
}
|
||||
const ghcupPath = `${process.env.HOME}/.ghcup${
|
||||
tool === 'ghc' ? `/ghc/${version}` : ''
|
||||
}/bin`;
|
||||
const v = tool === 'cabal' ? version.slice(0, 3) : version;
|
||||
const aptPath = `/opt/${tool}/${v}/bin`;
|
||||
|
||||
return {
|
||||
ghc: {
|
||||
version: core.getInput('ghc-version') || def.ghc.version,
|
||||
enable: !stackNoGlobal,
|
||||
install: installGHC
|
||||
},
|
||||
const chocoPath = join(
|
||||
`${process.env.ChocolateyInstall}`,
|
||||
'lib',
|
||||
`${tool}.${version}`,
|
||||
'tools',
|
||||
`${tool}-${version}`,
|
||||
tool === 'ghc' ? 'bin' : ''
|
||||
);
|
||||
|
||||
const locations = {
|
||||
stack: [], // Always installed into the tool cache
|
||||
cabal: {
|
||||
version: core.getInput('cabal-version') || def.cabal.version,
|
||||
enable: !stackNoGlobal,
|
||||
install: installCabal
|
||||
},
|
||||
stack: {
|
||||
version: stackVersion,
|
||||
enable: stackVersion !== '',
|
||||
install: installStack,
|
||||
setup: core.getInput('stack-setup-ghc') !== ''
|
||||
}
|
||||
win32: [chocoPath],
|
||||
linux: [aptPath],
|
||||
darwin: []
|
||||
}[os],
|
||||
ghc: {
|
||||
win32: [chocoPath],
|
||||
linux: [aptPath, ghcupPath],
|
||||
darwin: [ghcupPath]
|
||||
}[os]
|
||||
};
|
||||
|
||||
for (const p of locations[tool]) {
|
||||
const installedPath = await fs
|
||||
.access(p)
|
||||
.then(() => p)
|
||||
.catch(() => undefined);
|
||||
|
||||
if (installedPath) return success(tool, version, installedPath);
|
||||
}
|
||||
|
||||
if (tool === 'cabal' && os !== 'win32') {
|
||||
const installedPath = await fs
|
||||
.access(`${ghcupPath}/cabal`)
|
||||
.then(() => ghcupPath)
|
||||
.catch(() => undefined);
|
||||
|
||||
if (installedPath) return success(tool, version, installedPath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export const installCabal = async (version: string): Promise<void> =>
|
||||
installTool('cabal', version);
|
||||
export async function installTool(
|
||||
tool: Tool,
|
||||
version: string,
|
||||
os: OS
|
||||
): Promise<void> {
|
||||
if (await isInstalled(tool, version, os)) return;
|
||||
warn(tool, version);
|
||||
|
||||
export const installGHC = async (version: string): Promise<void> =>
|
||||
installTool('ghc', version);
|
||||
if (tool === 'stack') {
|
||||
await stack(version, os);
|
||||
if (await isInstalled(tool, version, os)) return;
|
||||
return failed(tool, version);
|
||||
}
|
||||
|
||||
export async function installStack(version: string): Promise<void> {
|
||||
const info =
|
||||
version === 'latest'
|
||||
? 'Installing the latest version'
|
||||
: `Installing version ${version}`;
|
||||
core.startGroup(`${info} of stack`);
|
||||
const platformMap = ({
|
||||
switch (os) {
|
||||
case 'linux':
|
||||
await apt(tool, version);
|
||||
if (await isInstalled(tool, version, os)) return;
|
||||
await ghcup(tool, version, os);
|
||||
break;
|
||||
case 'win32':
|
||||
await choco(tool, version);
|
||||
break;
|
||||
case 'darwin':
|
||||
await ghcup(tool, version, os);
|
||||
break;
|
||||
}
|
||||
|
||||
if (await isInstalled(tool, version, os)) return;
|
||||
return failed(tool, version);
|
||||
}
|
||||
|
||||
async function stack(version: string, os: OS): Promise<void> {
|
||||
core.info(`Attempting to install stack ${version}`);
|
||||
const build = {
|
||||
linux: 'linux-x86_64-static',
|
||||
darwin: 'osx-x86_64',
|
||||
win32: 'windows-x86_64'
|
||||
} as unknown) as Record<NodeJS.Platform, string>;
|
||||
}[os];
|
||||
|
||||
const name = `stack-${version}-${platformMap[process.platform]}`;
|
||||
const url =
|
||||
version === 'latest'
|
||||
? `get.haskellstack.org/stable/${platformMap[process.platform]}`
|
||||
: `github.com/commercialhaskell/stack/releases/download/v${version}/${name}`;
|
||||
? `https://get.haskellstack.org/stable/${build}.tar.gz`
|
||||
: `https://github.com/commercialhaskell/stack/releases/download/v${version}/stack-${version}-${build}.tar.gz`;
|
||||
const p = await tc.downloadTool(`${url}`).then(tc.extractTar);
|
||||
const [stackPath] = await glob(`${p}/stack*`, {
|
||||
implicitDescendants: false
|
||||
}).then(async g => g.glob());
|
||||
await tc.cacheDir(stackPath, 'stack', version);
|
||||
|
||||
const stack = await tc.downloadTool(`https://${url}.tar.gz`);
|
||||
const p = await tc.extractTar(stack);
|
||||
|
||||
// Less janky than figuring out how to ./p/*/stack. (Not by much)
|
||||
const stackPath =
|
||||
(await fs.readdir(p, {withFileTypes: true}))
|
||||
.flatMap(d => (d.isDirectory() ? [d.name] : []))
|
||||
.find(f => f.startsWith('stack')) ?? '';
|
||||
|
||||
const cachedTool = await tc.cacheDir(join(p, stackPath), 'stack', version);
|
||||
core.addPath(cachedTool);
|
||||
|
||||
core.endGroup();
|
||||
if (os === 'win32') core.exportVariable('STACK_ROOT', 'C:\\sr');
|
||||
}
|
||||
|
||||
type Tool = 'cabal' | 'ghc';
|
||||
async function installTool(tool: Tool, version: string): Promise<void> {
|
||||
core.startGroup(`Installing ${tool}`);
|
||||
// Linux comes pre-installed with some versions of GHC and supports older
|
||||
// versions through hvr's PPA.
|
||||
if (process.platform === 'linux') {
|
||||
// Cabal is installed to /opt/cabal/x.x but cabal's full version is X.X.Y.Z
|
||||
const v = tool === 'cabal' ? version.slice(0, 3) : version;
|
||||
|
||||
const p = join('/opt', tool, v, 'bin');
|
||||
const installed = await fs
|
||||
.access(p)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (tool === 'ghc' && !installed) {
|
||||
try {
|
||||
// hvr's PPA has better support for GHC < 8.0
|
||||
await exec(`sudo -- sh -c "apt-get -y install ghc-${v}"`);
|
||||
} catch {
|
||||
// oh well, we tried
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(p);
|
||||
core.addPath(p);
|
||||
core.endGroup();
|
||||
return;
|
||||
} catch {
|
||||
// ok, let's try the generic install now
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
await exec('powershell', [
|
||||
'choco',
|
||||
'install',
|
||||
tool,
|
||||
'--version',
|
||||
version,
|
||||
'-m',
|
||||
'--no-progress',
|
||||
'-r'
|
||||
]);
|
||||
|
||||
core.addPath(
|
||||
join(
|
||||
process.env.ChocolateyInstall || '',
|
||||
'lib',
|
||||
`${tool}.${version}`,
|
||||
'tools',
|
||||
`${tool}-${version}`,
|
||||
tool === 'ghc' ? 'bin' : ''
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const ghcup = await tc.downloadTool(
|
||||
'https://raw.githubusercontent.com/haskell/ghcup/master/ghcup'
|
||||
);
|
||||
await fs.chmod(ghcup, 0o755);
|
||||
await io.mkdirP(join(process.env.HOME || '', '.ghcup', 'bin'));
|
||||
await exec(ghcup, [tool === 'ghc' ? 'install' : 'install-cabal', version]);
|
||||
|
||||
const p = tool === 'ghc' ? ['ghc', version] : [];
|
||||
core.addPath(join(process.env.HOME || '', '.ghcup', ...p, 'bin'));
|
||||
}
|
||||
core.endGroup();
|
||||
async function apt(tool: Tool, version: string): Promise<void> {
|
||||
const toolName = tool === 'ghc' ? 'ghc' : 'cabal-install';
|
||||
const v = tool === 'cabal' ? version.slice(0, 3) : version;
|
||||
core.info(`Attempting to install ${toolName} ${v} using apt-get`);
|
||||
await exec(`sudo -- sh -c "apt-get -y install ${toolName}-${v}"`);
|
||||
}
|
||||
|
||||
async function choco(tool: Tool, version: string): Promise<void> {
|
||||
core.info(`Attempting to install ${tool} ${version} using chocolatey`);
|
||||
|
||||
await exec('powershell', [
|
||||
'choco',
|
||||
'install',
|
||||
tool,
|
||||
'--version',
|
||||
version,
|
||||
'-m',
|
||||
'--no-progress',
|
||||
'-r'
|
||||
]);
|
||||
}
|
||||
|
||||
async function ghcup(tool: Tool, version: string, os: OS): Promise<void> {
|
||||
core.info(`Attempting to install ${tool} ${version} using ghcup`);
|
||||
|
||||
const bin = await tc.downloadTool(
|
||||
`https://downloads.haskell.org/~ghcup/x86_64-${
|
||||
os === 'darwin' ? 'apple-darwin' : 'linux'
|
||||
}-ghcup`
|
||||
);
|
||||
await fs.chmod(bin, 0o755);
|
||||
|
||||
await exec(bin, [tool === 'ghc' ? 'install' : 'install-cabal', version]);
|
||||
if (tool === 'ghc') await exec(bin, ['set', version]);
|
||||
}
|
||||
|
||||
97
src/opts.ts
Normal file
97
src/opts.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import * as core from '@actions/core';
|
||||
import {readFileSync} from 'fs';
|
||||
import {safeLoad} from 'js-yaml';
|
||||
import {join} from 'path';
|
||||
import * as supported_versions from './versions.json';
|
||||
|
||||
export type OS = 'linux' | 'darwin' | 'win32';
|
||||
export type Tool = 'cabal' | 'ghc' | 'stack';
|
||||
|
||||
export interface ProgramOpt {
|
||||
enable: boolean;
|
||||
raw: string;
|
||||
resolved: string;
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
ghc: ProgramOpt;
|
||||
cabal: ProgramOpt;
|
||||
stack: ProgramOpt & {setup: boolean};
|
||||
}
|
||||
|
||||
type Version = {version: string; supported: string[]};
|
||||
export type Defaults = Record<Tool, Version>;
|
||||
|
||||
export function getDefaults(): Defaults {
|
||||
const inpts = safeLoad(
|
||||
readFileSync(join(__dirname, '..', 'action.yml'), 'utf8')
|
||||
).inputs;
|
||||
|
||||
const mkVersion = (v: string, vs: string[]): Version => ({
|
||||
version: resolve(inpts[v].default, vs),
|
||||
supported: vs
|
||||
});
|
||||
|
||||
return {
|
||||
ghc: mkVersion('ghc-version', supported_versions.ghc),
|
||||
cabal: mkVersion('cabal-version', supported_versions.cabal),
|
||||
stack: mkVersion('stack-version', supported_versions.stack)
|
||||
};
|
||||
}
|
||||
|
||||
function resolve(version: string, supported: string[]): string {
|
||||
return version === 'latest'
|
||||
? supported[0]
|
||||
: supported.find(v => v.startsWith(version)) ?? version;
|
||||
}
|
||||
|
||||
export function getOpts({ghc, cabal, stack}: Defaults): Options {
|
||||
const stackNoGlobal = core.getInput('stack-no-global') !== '';
|
||||
const stackSetupGhc = core.getInput('stack-setup-ghc') !== '';
|
||||
const stackEnable = core.getInput('enable-stack') !== '';
|
||||
const verInpt = {
|
||||
ghc: core.getInput('ghc-version') || ghc.version,
|
||||
cabal: core.getInput('cabal-version') || cabal.version,
|
||||
stack: core.getInput('stack-version') || stack.version
|
||||
};
|
||||
|
||||
const errors = [];
|
||||
if (stackNoGlobal && !stackEnable) {
|
||||
errors.push('enable-stack is required if stack-no-global is set');
|
||||
}
|
||||
|
||||
if (stackSetupGhc && !stackEnable) {
|
||||
errors.push('enable-stack is required if stack-setup-ghc is set');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join('\n'));
|
||||
}
|
||||
|
||||
const opts: Options = {
|
||||
ghc: {
|
||||
raw: verInpt.ghc,
|
||||
resolved: resolve(verInpt.ghc, ghc.supported),
|
||||
enable: !stackNoGlobal
|
||||
},
|
||||
cabal: {
|
||||
raw: verInpt.cabal,
|
||||
resolved: resolve(verInpt.cabal, cabal.supported),
|
||||
enable: !stackNoGlobal
|
||||
},
|
||||
stack: {
|
||||
raw: verInpt.stack,
|
||||
resolved: resolve(verInpt.stack, stack.supported),
|
||||
enable: stackEnable,
|
||||
setup: core.getInput('stack-setup-ghc') !== ''
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line github/array-foreach
|
||||
Object.values(opts)
|
||||
.filter(t => t.enable && t.raw !== t.resolved)
|
||||
.forEach(t => core.info(`Resolved ${t.raw} to ${t.resolved}`));
|
||||
|
||||
core.debug(`Options are: ${JSON.stringify(opts)}`);
|
||||
return opts;
|
||||
}
|
||||
@@ -1,38 +1,37 @@
|
||||
import * as core from '@actions/core';
|
||||
import {getOpts, getDefaults} from './installer';
|
||||
import {getOpts, getDefaults, Tool} from './opts';
|
||||
import {installTool} from './installer';
|
||||
import type {OS} from './opts';
|
||||
import {exec} from '@actions/exec';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const opts = getOpts(getDefaults());
|
||||
core.info('Preparing to setup a Haskell environment');
|
||||
core.debug(`Options are: ${JSON.stringify(opts)}`);
|
||||
const opts = getOpts(getDefaults());
|
||||
|
||||
for (const [tool, o] of Object.entries(opts)) {
|
||||
if (o.enable) {
|
||||
core.info(`Installing ${tool} version ${o.version}`);
|
||||
await o.install(o.version);
|
||||
}
|
||||
}
|
||||
for (const [t, {resolved}] of Object.entries(opts).filter(o => o[1].enable))
|
||||
await core.group(`Installing ${t} version ${resolved}`, async () =>
|
||||
installTool(t as Tool, resolved, process.platform as OS)
|
||||
);
|
||||
|
||||
if (opts.stack.setup) {
|
||||
core.startGroup('Pre-installing GHC with stack');
|
||||
await exec('stack', ['setup', opts.ghc.version]);
|
||||
core.endGroup();
|
||||
}
|
||||
if (opts.stack.setup)
|
||||
await core.group('Pre-installing GHC with stack', async () =>
|
||||
exec('stack', ['setup', opts.ghc.resolved])
|
||||
);
|
||||
|
||||
if (opts.cabal.enable) {
|
||||
core.startGroup('Setting up cabal');
|
||||
await exec('cabal', [
|
||||
'user-config',
|
||||
'update',
|
||||
'-a',
|
||||
'http-transport: plain-http',
|
||||
'-v3'
|
||||
]);
|
||||
await exec('cabal', ['update']);
|
||||
core.endGroup();
|
||||
}
|
||||
if (opts.cabal.enable)
|
||||
await core.group('Setting up cabal', async () => {
|
||||
await exec(
|
||||
'cabal user-config update -a "http-transport: plain-http" -v3'
|
||||
);
|
||||
await exec('cabal', ['update']);
|
||||
if (process.platform === 'win32') {
|
||||
await exec('cabal user-config update -a "store-dir: C:\\sr" -v3');
|
||||
core.setOutput('cabal-store', 'C:\\sr');
|
||||
} else {
|
||||
core.setOutput('cabal-store', `${process.env.HOME}/.cabal/store`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(error.message);
|
||||
}
|
||||
|
||||
37
src/versions.json
Normal file
37
src/versions.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"ghc": [
|
||||
"8.10.1",
|
||||
"8.8.3",
|
||||
"8.8.2",
|
||||
"8.8.1",
|
||||
"8.6.5",
|
||||
"8.6.4",
|
||||
"8.6.3",
|
||||
"8.6.2",
|
||||
"8.6.1",
|
||||
"8.4.4",
|
||||
"8.4.3",
|
||||
"8.4.2",
|
||||
"8.4.1",
|
||||
"8.2.2",
|
||||
"8.0.2",
|
||||
"7.10.3"
|
||||
],
|
||||
"cabal": ["3.2.0.0", "3.0.0.0", "2.4.1.0", "2.4.0.0", "2.2.0.0"],
|
||||
"stack": [
|
||||
"2.1.3",
|
||||
"2.1.1",
|
||||
"1.9.3",
|
||||
"1.9.1",
|
||||
"1.7.1",
|
||||
"1.6.5",
|
||||
"1.6.3",
|
||||
"1.6.1",
|
||||
"1.5.1",
|
||||
"1.5.0",
|
||||
"1.4.0",
|
||||
"1.3.2",
|
||||
"1.3.0",
|
||||
"1.2.0"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user