diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 9a32d34..e77da72 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -43,6 +43,9 @@ jobs: matrix: os: [ubuntu-latest, macOS-latest, windows-latest] plan: + # Nightly releases + - ghc: latest-nightly + cabal: latest # Latest releases - ghc: latest cabal: latest @@ -90,13 +93,29 @@ jobs: # and of cabal just 2.4, 3.0, 3.2, 3.4 # according to https://launchpad.net/~hvr/+archive/ubuntu/ghc?field.series_filter=focal - # Test ghcup pre-release channel + # Any matrix combinations with latest-nightly should add the appropriate release channel + - plan: + ghc: latest-nightly + ghcup_release_channels: | + https://ghc.gitlab.haskell.org/ghcup-metadata/ghcup-nightlies-0.0.7.yaml + + # Test deprecated release channel still works for now - os: ubuntu-latest ghcup_release_channel: "https://raw.githubusercontent.com/haskell/ghcup-metadata/master/ghcup-prereleases-0.0.7.yaml" plan: ghc: "9.6.0.20230111" cabal: "3.8" + # Test ghcup release channels + - os: ubuntu-latest + ghcup_release_channels: | + https://raw.githubusercontent.com/haskell/ghcup-metadata/master/ghcup-0.0.7.yaml + https://raw.githubusercontent.com/haskell/ghcup-metadata/master/ghcup-prereleases-0.0.7.yaml + https://raw.githubusercontent.com/haskell/ghcup-metadata/master/ghcup-vanilla-0.0.7.yaml + plan: + ghc: "9.6.0.20230111" + cabal: "3.8" + # setup does something special for 7.10.3 (issue #79) - os: ubuntu-20.04 plan: @@ -143,6 +162,7 @@ jobs: with: ghc-version: ${{ matrix.plan.ghc }} ghcup-release-channel: ${{ matrix.ghcup_release_channel }} + ghcup-release-channels: ${{ matrix.ghcup_release_channels }} cabal-version: ${{ matrix.plan.cabal }} cabal-update: ${{ matrix.cabal_update }} diff --git a/README.md b/README.md index 3813355..69e6eba 100644 --- a/README.md +++ b/README.md @@ -184,23 +184,33 @@ jobs: ## Inputs -| Name | Description | Type | Default | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ----------- | -| `ghc-version` | GHC version to use, e.g. `9.2` or `9.2.5`. | `string` | `latest` | -| `cabal-version` | Cabal version to use, e.g. `3.6`. | `string` | `latest` | -| `stack-version` | Stack version to use, e.g. `latest`. Stack will only be installed if `enable-stack` is set. | `string` | `latest` | -| `enable-stack` | If set, will setup Stack. | "boolean" | false/unset | -| `stack-no-global` | If set, `enable-stack` must be set. Prevents installing GHC and Cabal globally. | "boolean" | false/unset | -| `stack-setup-ghc` | If set, `enable-stack` must be set. Runs stack setup to install the specified GHC. (Note: setting this does _not_ imply `stack-no-global`.) | "boolean" | false/unset | -| `disable-matcher` | If set, disables match messages from GHC as GitHub CI annotations. | "boolean" | false/unset | -| `cabal-update` | If set to `false`, skip `cabal update` step. | `boolean` | `true` | -| `ghcup-release-channel` | If set, add a [release channel](https://www.haskell.org/ghcup/guide/#pre-release-channels) to ghcup. | `URL` | none | - -Note: "boolean" types are set/unset, not true/false. -That is, setting any "boolean" to a value other than the empty string (`""`) will be considered true/set. -However, to avoid confusion and for forward compatibility, it is still recommended to **only use value `true` to set a "boolean" flag.** - -In contrast, a proper `boolean` input like `cabal-update` only accepts values `true` and `false`. +| Name | Description | Type | Default | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ----------- | +| `ghc-version` | GHC version to use, e.g. `9.2` or `9.2.5`. | `string` | `latest` | +| `cabal-version` | Cabal version to use, e.g. `3.6`. | `string` | `latest` | +| `stack-version` | Stack version to use, e.g. `latest`. Stack will only be installed if `enable-stack` is set. | `string` | `latest` | +| `enable-stack` | If set, will setup Stack. | `Toggle` | false/unset | +| `stack-no-global` | If set, `enable-stack` must be set. Prevents installing GHC and Cabal globally. | `Toggle` | false/unset | +| `stack-setup-ghc` | If set, `enable-stack` must be set. Runs stack setup to install the specified GHC. (Note: setting this does _not_ imply `stack-no-global`.) | `Toggle` | false/unset | +| `disable-matcher` | If set, disables match messages from GHC as GitHub CI annotations. | `Toggle` | false/unset | +| `cabal-update` | If set to `false`, skip `cabal update` step. | `boolean` | `true` | +| `ghcup-release-channels` | If set, add [release channels](https://www.haskell.org/ghcup/guide/#pre-release-channels) to ghcup. | `URL[]` | none | + +Notes: + +- `Toggle` inputs are booleans that are false when set as the empty string and true when set to _anything_. + However, to avoid confusion and for forward compatibility, it is still recommended to **only use value `true` to set a `Toggle` input.** + +- Inputs that can take multiple values (like `ghcup-release-channels`) should be specified as a multiline list, e.g. + + ```yaml + - uses: haskell-actions/setup@v2 + with: + ghcup-release-channels: | + https://example.com/channel1 + https://example.com/channel2 + https://example.com/channel3 + ``` ## Outputs @@ -236,6 +246,8 @@ E.g., `8.10` will be resolved to `8.10.7`, and so will `8`. **GHC:** +- `latest-nightly` + - This requires adding https://ghc.gitlab.haskell.org/ghcup-metadata/ghcup-nightlies-0.0.7.yaml to `ghcup-release-channels` - `latest` (default) - `9.6.2` `9.6` - `9.6.1` diff --git a/__tests__/find-haskell.test.ts b/__tests__/find-haskell.test.ts index 5aaa396..28f7222 100644 --- a/__tests__/find-haskell.test.ts +++ b/__tests__/find-haskell.test.ts @@ -46,7 +46,7 @@ describe('haskell/actions/setup', () => { it('Setting disable-matcher to true disables matcher', () => { forAllOS(os => { const options = getOpts(def(os), os, { - 'disable-matcher': 'true' + disableMatcher: true }); expect(options.general.matcher.enable).toBe(false); }); @@ -70,10 +70,10 @@ describe('haskell/actions/setup', () => { const v = {ghc: '8.6.5', cabal: '3.4.1.0', stack: '1.9.3'}; forAllOS(os => { const options = getOpts(def(os), os, { - 'enable-stack': 'true', - 'stack-version': '1', - 'ghc-version': '8.6', - 'cabal-version': '3.4' + enableStack: true, + stackVersion: '1', + ghcVersion: '8.6', + cabalVersion: '3.4' }); forAllTools(t => expect(options[t].resolved).toBe(v[t])); }); @@ -82,10 +82,10 @@ describe('haskell/actions/setup', () => { it('"latest" Versions resolve correctly', () => { forAllOS(os => { const options = getOpts(def(os), os, { - 'enable-stack': 'true', - 'stack-version': 'latest', - 'ghc-version': 'latest', - 'cabal-version': 'latest' + enableStack: true, + stackVersion: 'latest', + ghcVersion: 'latest', + cabalVersion: 'latest' }); forAllTools(t => expect(options[t].resolved).toBe( @@ -100,10 +100,10 @@ describe('haskell/actions/setup', () => { const v = {ghc: '8.10.7', cabal: '2.4.1.0', stack: '2.1.3'}; forAllOS(os => { const options = getOpts(def(os), os, { - 'enable-stack': 'true', - 'stack-version': '2.1', - 'ghc-version': '8.10', - 'cabal-version': '2' + enableStack: true, + stackVersion: '2.1', + ghcVersion: '8.10', + cabalVersion: '2' }); forAllTools(t => expect(options[t].resolved).toBe(v[t])); }); @@ -112,7 +112,7 @@ describe('haskell/actions/setup', () => { it('Enabling stack does not disable GHC or Cabal', () => { forAllOS(os => { const {ghc, cabal, stack} = getOpts(def(os), os, { - 'enable-stack': 'true' + enableStack: true }); expect({ ghc: ghc.enable, @@ -125,20 +125,20 @@ describe('haskell/actions/setup', () => { it('Resolves revisions correctly on Windows', () => { // Test the case where there is a revision in chocolatey expect( - getOpts(def('win32'), 'win32', {'ghc-version': '8.10.2'}).ghc.resolved + getOpts(def('win32'), 'win32', {ghcVersion: '8.10.2'}).ghc.resolved ).toBe('8.10.2'); // Andreas, 2022-12-29: revisions are handled locally in choco() now // Test the case where there is not a revision in chocolatey expect( - getOpts(def('win32'), 'win32', {'ghc-version': '8.8.1'}).ghc.resolved + getOpts(def('win32'), 'win32', {ghcVersion: '8.8.1'}).ghc.resolved ).toBe('8.8.1'); }); it('Enabling stack-no-global disables GHC and Cabal', () => { forAllOS(os => { const {ghc, cabal, stack} = getOpts(def(os), os, { - 'enable-stack': 'true', - 'stack-no-global': 'true' + enableStack: true, + stackNoGlobal: true }); expect({ ghc: ghc.enable, @@ -150,13 +150,13 @@ describe('haskell/actions/setup', () => { it('Enabling stack-no-global without setting enable-stack errors', () => { forAllOS(os => - expect(() => getOpts(def(os), os, {'stack-no-global': 'true'})).toThrow() + expect(() => getOpts(def(os), os, {stackNoGlobal: true})).toThrow() ); }); it('Enabling stack-setup-ghc without setting enable-stack errors', () => { forAllOS(os => - expect(() => getOpts(def(os), os, {'stack-setup-ghc': 'true'})).toThrow() + expect(() => getOpts(def(os), os, {stackNoGlobal: true})).toThrow() ); }); }); diff --git a/action.yml b/action.yml index ba95487..6f4c012 100644 --- a/action.yml +++ b/action.yml @@ -4,7 +4,7 @@ author: 'GitHub' inputs: ghc-version: required: false - description: 'Version of GHC to use. If set to "latest", it will always get the latest stable version. If set to "head", it will always get the latest build of GHC.' + description: 'Version of GHC to use. If set to "latest", it will always get the latest stable version. If set to "latest-nightly", it will always get the latest nightly version of GHC' default: 'latest' cabal-version: required: false @@ -30,9 +30,12 @@ inputs: # Note: 'cabal-update' only accepts 'true' and 'false' as values. # This is different from the other flags ('enable-stack', 'disable-matcher' etc.) # which are true as soon as they are not null. + ghcup-release-channels: + required: false + description: "Release channel URLs to add to ghcup via `ghcup config add-release-channel`, as a multiline string" ghcup-release-channel: required: false - description: "A release channel URL to add to ghcup via `ghcup config add-release-channel`." + description: "Deprecated by ghcup-release-channels." disable-matcher: required: false description: 'If specified, disables match messages from GHC as GitHub CI annotations.' diff --git a/dist/index.js b/dist/index.js index 4461716..fbb83c3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -13312,6 +13312,7 @@ const core = __importStar(__nccwpck_require__(2186)); const exec_1 = __nccwpck_require__(1514); const io_1 = __nccwpck_require__(7436); const tc = __importStar(__nccwpck_require__(7784)); +const child_process = __importStar(__nccwpck_require__(2081)); const fs_1 = __nccwpck_require__(7147); const path_1 = __nccwpck_require__(1017); const opts_1 = __nccwpck_require__(8131); @@ -13321,7 +13322,7 @@ const fs = __importStar(__nccwpck_require__(7147)); const compare_versions_1 = __nccwpck_require__(4773); // compareVersions can be used in the sense of > // Don't throw on non-zero. const exec = async (cmd, args) => (0, exec_1.exec)(cmd, args, { ignoreReturnCode: true }); -function failed(tool, version) { +async function failed(tool, version) { throw new Error(`All install methods for ${tool} ${version} failed`); } async function configureOutputs(tool, version, path, os) { @@ -13334,7 +13335,12 @@ async function configureOutputs(tool, version, path, os) { if (os === 'win32') core.exportVariable('STACK_ROOT', sr); } - core.setOutput(`${tool}-version`, version); + // can't put this in resolve() because it might run before ghcup is installed + let resolvedVersion = version; + if (version === 'latest-nightly') { + resolvedVersion = (await getLatestNightlyFromGhcup(os)) ?? version; + } + core.setOutput(`${tool}-version`, resolvedVersion); } async function success(tool, version, path, os) { core.addPath(path); @@ -13436,18 +13442,14 @@ async function installTool(tool, version, os) { } switch (os) { case 'linux': - if (tool === 'ghc' && version === 'head') { - if (!(await aptBuildEssential())) - break; - await ghcupGHCHead(); - break; - } - if (tool === 'ghc' && (0, compare_versions_1.compareVersions)('8.3', version)) { - // Andreas, 2022-12-09: The following errors out if we are not ubuntu-20.04. - // Atm, I do not know how to check whether we are on ubuntu-20.04. - // So, ignore the error. - // if (!(await aptLibCurses5())) break; - await aptLibNCurses5(); + if (tool === 'ghc') { + if (version !== 'latest-nightly' && (0, compare_versions_1.compareVersions)('8.3', version)) { + // Andreas, 2022-12-09: The following errors out if we are not ubuntu-20.04. + // Atm, I do not know how to check whether we are on ubuntu-20.04. + // So, ignore the error. + // if (!(await aptLibCurses5())) break; + await aptLibNCurses5(); + } } await ghcup(tool, version, os); if (await isInstalled(tool, version, os)) @@ -13508,11 +13510,6 @@ async function stack(version, os) { .then(async (g) => g.glob()); await tc.cacheDir(stackPath, 'stack', version); } -async function aptBuildEssential() { - core.info(`Installing build-essential using apt-get (for ghc-head)`); - const returnCode = await exec(`sudo -- sh -c "apt-get update && apt-get -y install build-essential"`); - return returnCode === 0; -} async function aptLibNCurses5() { core.info(`Installing libcurses5 and libtinfo5 using apt-get (for ghc < 8.3)`); const returnCode = await exec(`sudo -- sh -c "apt-get update && apt-get -y install libncurses5 libtinfo5"`); @@ -13574,6 +13571,34 @@ async function addGhcupReleaseChannel(channel, os) { await exec(bin, ['config', 'add-release-channel', channel.toString()]); } exports.addGhcupReleaseChannel = addGhcupReleaseChannel; +/** + * Get the resolved version of the `latest-nightly` GHC tag from ghcup, + * e.g. '9.9.20230711' + */ +async function getLatestNightlyFromGhcup(os) { + try { + const ghcupExe = await ghcupBin(os); + /* Example output: + + ghc 9.0.2 base-4.15.1.0 + ghc 9.7.20230526 nightly 2023-06-27 + ghc 9.9.20230711 latest-nightly 2023-07-12 + cabal 2.4.1.0 no-bindist + cabal 3.6.2.0 recommended + */ + const out = await new Promise((resolve, reject) => child_process.execFile(ghcupExe, ['list', '-nr'], { encoding: 'utf-8' }, (error, stdout) => (error ? reject(error) : resolve(stdout)))); + return (out + .split('\n') + .map(line => line.split(' ')) + .filter(line => line[2] === 'latest-nightly') + .map(line => line[1]) + .at(0) ?? null); + } + catch (error) { + // swallow failures, just return null + return null; + } +} async function ghcup(tool, version, os) { core.info(`Attempting to install ${tool} ${version} using ghcup`); const bin = await ghcupBin(os); @@ -13581,19 +13606,6 @@ async function ghcup(tool, version, os) { if (returnCode === 0) await exec(bin, ['set', tool, version]); } -async function ghcupGHCHead() { - core.info(`Attempting to install ghc head using ghcup`); - const bin = await ghcupBin('linux'); - const returnCode = await exec(bin, [ - 'install', - 'ghc', - '-u', - 'https://gitlab.haskell.org/ghc/ghc/-/jobs/artifacts/master/raw/ghc-x86_64-deb9-linux-integer-simple.tar.xz?job=validate-x86_64-linux-deb9-integer-simple', - 'head' - ]); - if (returnCode === 0) - await exec(bin, ['set', 'ghc', 'head']); -} async function getChocoPath(tool, version, revision) { // Environment variable 'ChocolateyToolsLocation' will be added to Hosted images soon // fallback to C:\\tools for now until variable is available @@ -13653,9 +13665,27 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", ({ value: true })); const core = __importStar(__nccwpck_require__(2186)); -const opts_1 = __nccwpck_require__(8131); const setup_haskell_1 = __importDefault(__nccwpck_require__(9351)); -(0, setup_haskell_1.default)(Object.fromEntries(Object.keys(opts_1.yamlInputs).map(k => [k, core.getInput(k)]))); +const getToggleInput = (name) => core.getInput(name) !== ''; +const getBooleanInput = (name) => { + // https://github.com/actions/toolkit/issues/844 + if (!process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`]) { + return undefined; + } + return core.getBooleanInput(name); +}; +(0, setup_haskell_1.default)({ + ghcVersion: core.getInput('ghc-version'), + cabalVersion: core.getInput('cabal-version'), + stackVersion: core.getInput('stack-version'), + enableStack: getToggleInput('enable-stack'), + stackNoGlobal: getToggleInput('stack-no-global'), + stackSetupGhc: getToggleInput('stack-setup-ghc'), + cabalUpdate: getBooleanInput('cabal-update'), + ghcupReleaseChannels: core.getMultilineInput('ghcup-release-channels'), + ghcupReleaseChannel: core.getInput('ghcup-release-channel'), + disableMatcher: getToggleInput('disable-matcher') +}); /***/ }), @@ -13689,7 +13719,7 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.getOpts = exports.parseURL = exports.parseYAMLBoolean = exports.releaseRevision = exports.getDefaults = exports.yamlInputs = exports.ghcup_version = exports.supported_versions = exports.release_revisions = void 0; +exports.getOpts = exports.releaseRevision = exports.getDefaults = exports.yamlInputs = exports.ghcup_version = exports.supported_versions = exports.release_revisions = void 0; const core = __importStar(__nccwpck_require__(2186)); const fs_1 = __nccwpck_require__(7147); const js_yaml_1 = __nccwpck_require__(1917); @@ -13768,55 +13798,30 @@ function releaseRevision(version, tool, os) { return result; } exports.releaseRevision = releaseRevision; -/** - * Convert a string input to a boolean according to the YAML 1.2 "core schema" specification. - * Supported boolean renderings: `true | True | TRUE | false | False | FALSE` . - * ref: https://yaml.org/spec/1.2/spec.html#id2804923 - * Adapted from: https://github.com/actions/toolkit/commit/fbdf27470cdcb52f16755d32082f1fee0bfb7d6d#diff-f63fb32fca85d8e177d6400ce078818a4815b80ac7a3319b60d3507354890992R94-R115 - * - * @param name name of the input - * @param val supposed string representation of a boolean - * @returns boolean - */ -function parseYAMLBoolean(name, val) { - const trueValue = ['true', 'True', 'TRUE']; - const falseValue = ['false', 'False', 'FALSE']; - if (trueValue.includes(val)) - return true; - if (falseValue.includes(val)) - return false; - throw new TypeError(`Action input "${name}" does not meet YAML 1.2 "Core Schema" specification: \n` + - `Supported boolean values: \`true | True | TRUE | false | False | FALSE\``); -} -exports.parseYAMLBoolean = parseYAMLBoolean; -function parseURL(name, val) { - if (val === '') - return undefined; - try { - return new URL(val); - } - catch (e) { - throw new TypeError(`Action input "${name}" is not a valid URL`); - } -} -exports.parseURL = parseURL; function getOpts({ ghc, cabal, stack }, os, inputs) { core.debug(`Inputs are: ${JSON.stringify(inputs)}`); - const stackNoGlobal = (inputs['stack-no-global'] || '') !== ''; - const stackSetupGhc = (inputs['stack-setup-ghc'] || '') !== ''; - const stackEnable = (inputs['enable-stack'] || '') !== ''; - const matcherDisable = (inputs['disable-matcher'] || '') !== ''; - const ghcupReleaseChannel = parseURL('ghcup-release-channel', inputs['ghcup-release-channel'] || ''); - // Andreas, 2023-01-05, issue #29: - // 'cabal-update' has a default value, so we should get a proper boolean always. - // Andreas, 2023-01-06: This is not true if we use the action as a library. - // Thus, need to patch with default value here. - const cabalUpdate = parseYAMLBoolean('cabal-update', inputs['cabal-update'] || 'true'); + const stackNoGlobal = inputs.stackNoGlobal ?? false; + const stackSetupGhc = inputs.stackSetupGhc ?? false; + const stackEnable = inputs.enableStack ?? false; + const cabalUpdate = inputs.cabalUpdate ?? true; + const matcherDisable = inputs.disableMatcher ?? false; + if (inputs.ghcupReleaseChannel) { + core.warning('ghcup-release-channel is deprecated in favor of ghcup-release-channels'); + inputs.ghcupReleaseChannels = [inputs.ghcupReleaseChannel]; + } + const ghcupReleaseChannels = (inputs.ghcupReleaseChannels ?? []).map(v => { + try { + return new URL(v); + } + catch (e) { + throw new TypeError(`Not a valid URL: ${v}`); + } + }); core.debug(`${stackNoGlobal}/${stackSetupGhc}/${stackEnable}`); const verInpt = { - ghc: inputs['ghc-version'] || ghc.version, - cabal: inputs['cabal-version'] || cabal.version, - stack: inputs['stack-version'] || stack.version + ghc: inputs.ghcVersion || ghc.version, + cabal: inputs.cabalVersion || cabal.version, + stack: inputs.stackVersion || stack.version }; const errors = []; if (stackNoGlobal && !stackEnable) { @@ -13838,7 +13843,7 @@ function getOpts({ ghc, cabal, stack }, os, inputs) { enable: ghcEnable }, ghcup: { - releaseChannel: ghcupReleaseChannel + releaseChannels: ghcupReleaseChannels }, cabal: { raw: verInpt.cabal, @@ -13922,8 +13927,12 @@ async function run(inputs) { core.debug(`run: inputs = ${JSON.stringify(inputs)}`); core.debug(`run: os = ${JSON.stringify(os)}`); core.debug(`run: opts = ${JSON.stringify(opts)}`); - if (opts.ghcup.releaseChannel) { - await core.group(`Preparing ghcup environment`, async () => (0, installer_1.addGhcupReleaseChannel)(opts.ghcup.releaseChannel, os)); + if (opts.ghcup.releaseChannels.length > 0) { + await core.group(`Setting release channels`, async () => { + for (const channel of opts.ghcup.releaseChannels) { + await (0, installer_1.addGhcupReleaseChannel)(channel, os); + } + }); } for (const [t, { resolved }] of Object.entries(opts).filter(o => o[1].enable)) { await core.group(`Preparing ${t} environment`, async () => (0, installer_1.resetTool)(t, resolved, os)); diff --git a/lib/installer.js b/lib/installer.js index 70c9429..a42db22 100644 --- a/lib/installer.js +++ b/lib/installer.js @@ -31,6 +31,7 @@ const core = __importStar(require("@actions/core")); const exec_1 = require("@actions/exec"); const io_1 = require("@actions/io"); const tc = __importStar(require("@actions/tool-cache")); +const child_process = __importStar(require("child_process")); const fs_1 = require("fs"); const path_1 = require("path"); const opts_1 = require("./opts"); @@ -40,7 +41,7 @@ const fs = __importStar(require("fs")); const compare_versions_1 = require("compare-versions"); // compareVersions can be used in the sense of > // Don't throw on non-zero. const exec = async (cmd, args) => (0, exec_1.exec)(cmd, args, { ignoreReturnCode: true }); -function failed(tool, version) { +async function failed(tool, version) { throw new Error(`All install methods for ${tool} ${version} failed`); } async function configureOutputs(tool, version, path, os) { @@ -53,7 +54,12 @@ async function configureOutputs(tool, version, path, os) { if (os === 'win32') core.exportVariable('STACK_ROOT', sr); } - core.setOutput(`${tool}-version`, version); + // can't put this in resolve() because it might run before ghcup is installed + let resolvedVersion = version; + if (version === 'latest-nightly') { + resolvedVersion = (await getLatestNightlyFromGhcup(os)) ?? version; + } + core.setOutput(`${tool}-version`, resolvedVersion); } async function success(tool, version, path, os) { core.addPath(path); @@ -155,18 +161,14 @@ async function installTool(tool, version, os) { } switch (os) { case 'linux': - if (tool === 'ghc' && version === 'head') { - if (!(await aptBuildEssential())) - break; - await ghcupGHCHead(); - break; - } - if (tool === 'ghc' && (0, compare_versions_1.compareVersions)('8.3', version)) { - // Andreas, 2022-12-09: The following errors out if we are not ubuntu-20.04. - // Atm, I do not know how to check whether we are on ubuntu-20.04. - // So, ignore the error. - // if (!(await aptLibCurses5())) break; - await aptLibNCurses5(); + if (tool === 'ghc') { + if (version !== 'latest-nightly' && (0, compare_versions_1.compareVersions)('8.3', version)) { + // Andreas, 2022-12-09: The following errors out if we are not ubuntu-20.04. + // Atm, I do not know how to check whether we are on ubuntu-20.04. + // So, ignore the error. + // if (!(await aptLibCurses5())) break; + await aptLibNCurses5(); + } } await ghcup(tool, version, os); if (await isInstalled(tool, version, os)) @@ -227,11 +229,6 @@ async function stack(version, os) { .then(async (g) => g.glob()); await tc.cacheDir(stackPath, 'stack', version); } -async function aptBuildEssential() { - core.info(`Installing build-essential using apt-get (for ghc-head)`); - const returnCode = await exec(`sudo -- sh -c "apt-get update && apt-get -y install build-essential"`); - return returnCode === 0; -} async function aptLibNCurses5() { core.info(`Installing libcurses5 and libtinfo5 using apt-get (for ghc < 8.3)`); const returnCode = await exec(`sudo -- sh -c "apt-get update && apt-get -y install libncurses5 libtinfo5"`); @@ -293,6 +290,34 @@ async function addGhcupReleaseChannel(channel, os) { await exec(bin, ['config', 'add-release-channel', channel.toString()]); } exports.addGhcupReleaseChannel = addGhcupReleaseChannel; +/** + * Get the resolved version of the `latest-nightly` GHC tag from ghcup, + * e.g. '9.9.20230711' + */ +async function getLatestNightlyFromGhcup(os) { + try { + const ghcupExe = await ghcupBin(os); + /* Example output: + + ghc 9.0.2 base-4.15.1.0 + ghc 9.7.20230526 nightly 2023-06-27 + ghc 9.9.20230711 latest-nightly 2023-07-12 + cabal 2.4.1.0 no-bindist + cabal 3.6.2.0 recommended + */ + const out = await new Promise((resolve, reject) => child_process.execFile(ghcupExe, ['list', '-nr'], { encoding: 'utf-8' }, (error, stdout) => (error ? reject(error) : resolve(stdout)))); + return (out + .split('\n') + .map(line => line.split(' ')) + .filter(line => line[2] === 'latest-nightly') + .map(line => line[1]) + .at(0) ?? null); + } + catch (error) { + // swallow failures, just return null + return null; + } +} async function ghcup(tool, version, os) { core.info(`Attempting to install ${tool} ${version} using ghcup`); const bin = await ghcupBin(os); @@ -300,19 +325,6 @@ async function ghcup(tool, version, os) { if (returnCode === 0) await exec(bin, ['set', tool, version]); } -async function ghcupGHCHead() { - core.info(`Attempting to install ghc head using ghcup`); - const bin = await ghcupBin('linux'); - const returnCode = await exec(bin, [ - 'install', - 'ghc', - '-u', - 'https://gitlab.haskell.org/ghc/ghc/-/jobs/artifacts/master/raw/ghc-x86_64-deb9-linux-integer-simple.tar.xz?job=validate-x86_64-linux-deb9-integer-simple', - 'head' - ]); - if (returnCode === 0) - await exec(bin, ['set', 'ghc', 'head']); -} async function getChocoPath(tool, version, revision) { // Environment variable 'ChocolateyToolsLocation' will be added to Hosted images soon // fallback to C:\\tools for now until variable is available diff --git a/lib/opts.d.ts b/lib/opts.d.ts index 5b5a360..573aab6 100644 --- a/lib/opts.d.ts +++ b/lib/opts.d.ts @@ -16,7 +16,7 @@ export interface ProgramOpt { export interface Options { ghc: ProgramOpt; ghcup: { - releaseChannel?: URL; + releaseChannels: URL[]; }; cabal: ProgramOpt & { update: boolean; @@ -74,17 +74,17 @@ export declare const yamlInputs: Record; export declare function getDefaults(os: OS): Defaults; export declare function releaseRevision(version: string, tool: Tool, os: OS): string; -/** - * Convert a string input to a boolean according to the YAML 1.2 "core schema" specification. - * Supported boolean renderings: `true | True | TRUE | false | False | FALSE` . - * ref: https://yaml.org/spec/1.2/spec.html#id2804923 - * Adapted from: https://github.com/actions/toolkit/commit/fbdf27470cdcb52f16755d32082f1fee0bfb7d6d#diff-f63fb32fca85d8e177d6400ce078818a4815b80ac7a3319b60d3507354890992R94-R115 - * - * @param name name of the input - * @param val supposed string representation of a boolean - * @returns boolean - */ -export declare function parseYAMLBoolean(name: string, val: string): boolean; -export declare function parseURL(name: string, val: string): URL | undefined; -export declare function getOpts({ ghc, cabal, stack }: Defaults, os: OS, inputs: Record): Options; +export type RawInputs = { + ghcVersion?: string; + cabalVersion?: string; + stackVersion?: string; + enableStack?: boolean; + stackNoGlobal?: boolean; + stackSetupGhc?: boolean; + cabalUpdate?: boolean; + ghcupReleaseChannels?: string[]; + ghcupReleaseChannel?: string; + disableMatcher?: boolean; +}; +export declare function getOpts({ ghc, cabal, stack }: Defaults, os: OS, inputs: RawInputs): Options; export {}; diff --git a/lib/opts.js b/lib/opts.js index fb933de..c1b9e09 100644 --- a/lib/opts.js +++ b/lib/opts.js @@ -23,7 +23,7 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getOpts = exports.parseURL = exports.parseYAMLBoolean = exports.releaseRevision = exports.getDefaults = exports.yamlInputs = exports.ghcup_version = exports.supported_versions = exports.release_revisions = void 0; +exports.getOpts = exports.releaseRevision = exports.getDefaults = exports.yamlInputs = exports.ghcup_version = exports.supported_versions = exports.release_revisions = void 0; const core = __importStar(require("@actions/core")); const fs_1 = require("fs"); const js_yaml_1 = require("js-yaml"); @@ -102,55 +102,30 @@ function releaseRevision(version, tool, os) { return result; } exports.releaseRevision = releaseRevision; -/** - * Convert a string input to a boolean according to the YAML 1.2 "core schema" specification. - * Supported boolean renderings: `true | True | TRUE | false | False | FALSE` . - * ref: https://yaml.org/spec/1.2/spec.html#id2804923 - * Adapted from: https://github.com/actions/toolkit/commit/fbdf27470cdcb52f16755d32082f1fee0bfb7d6d#diff-f63fb32fca85d8e177d6400ce078818a4815b80ac7a3319b60d3507354890992R94-R115 - * - * @param name name of the input - * @param val supposed string representation of a boolean - * @returns boolean - */ -function parseYAMLBoolean(name, val) { - const trueValue = ['true', 'True', 'TRUE']; - const falseValue = ['false', 'False', 'FALSE']; - if (trueValue.includes(val)) - return true; - if (falseValue.includes(val)) - return false; - throw new TypeError(`Action input "${name}" does not meet YAML 1.2 "Core Schema" specification: \n` + - `Supported boolean values: \`true | True | TRUE | false | False | FALSE\``); -} -exports.parseYAMLBoolean = parseYAMLBoolean; -function parseURL(name, val) { - if (val === '') - return undefined; - try { - return new URL(val); - } - catch (e) { - throw new TypeError(`Action input "${name}" is not a valid URL`); - } -} -exports.parseURL = parseURL; function getOpts({ ghc, cabal, stack }, os, inputs) { core.debug(`Inputs are: ${JSON.stringify(inputs)}`); - const stackNoGlobal = (inputs['stack-no-global'] || '') !== ''; - const stackSetupGhc = (inputs['stack-setup-ghc'] || '') !== ''; - const stackEnable = (inputs['enable-stack'] || '') !== ''; - const matcherDisable = (inputs['disable-matcher'] || '') !== ''; - const ghcupReleaseChannel = parseURL('ghcup-release-channel', inputs['ghcup-release-channel'] || ''); - // Andreas, 2023-01-05, issue #29: - // 'cabal-update' has a default value, so we should get a proper boolean always. - // Andreas, 2023-01-06: This is not true if we use the action as a library. - // Thus, need to patch with default value here. - const cabalUpdate = parseYAMLBoolean('cabal-update', inputs['cabal-update'] || 'true'); + const stackNoGlobal = inputs.stackNoGlobal ?? false; + const stackSetupGhc = inputs.stackSetupGhc ?? false; + const stackEnable = inputs.enableStack ?? false; + const cabalUpdate = inputs.cabalUpdate ?? true; + const matcherDisable = inputs.disableMatcher ?? false; + if (inputs.ghcupReleaseChannel) { + core.warning('ghcup-release-channel is deprecated in favor of ghcup-release-channels'); + inputs.ghcupReleaseChannels = [inputs.ghcupReleaseChannel]; + } + const ghcupReleaseChannels = (inputs.ghcupReleaseChannels ?? []).map(v => { + try { + return new URL(v); + } + catch (e) { + throw new TypeError(`Not a valid URL: ${v}`); + } + }); core.debug(`${stackNoGlobal}/${stackSetupGhc}/${stackEnable}`); const verInpt = { - ghc: inputs['ghc-version'] || ghc.version, - cabal: inputs['cabal-version'] || cabal.version, - stack: inputs['stack-version'] || stack.version + ghc: inputs.ghcVersion || ghc.version, + cabal: inputs.cabalVersion || cabal.version, + stack: inputs.stackVersion || stack.version }; const errors = []; if (stackNoGlobal && !stackEnable) { @@ -172,7 +147,7 @@ function getOpts({ ghc, cabal, stack }, os, inputs) { enable: ghcEnable }, ghcup: { - releaseChannel: ghcupReleaseChannel + releaseChannels: ghcupReleaseChannels }, cabal: { raw: verInpt.cabal, diff --git a/lib/setup-haskell.d.ts b/lib/setup-haskell.d.ts index 10f9366..764b778 100644 --- a/lib/setup-haskell.d.ts +++ b/lib/setup-haskell.d.ts @@ -1 +1,2 @@ -export default function run(inputs: Record): Promise; +import { RawInputs } from './opts'; +export default function run(inputs: RawInputs): Promise; diff --git a/lib/setup-haskell.js b/lib/setup-haskell.js index 4285907..8d4f8fc 100644 --- a/lib/setup-haskell.js +++ b/lib/setup-haskell.js @@ -52,8 +52,12 @@ async function run(inputs) { core.debug(`run: inputs = ${JSON.stringify(inputs)}`); core.debug(`run: os = ${JSON.stringify(os)}`); core.debug(`run: opts = ${JSON.stringify(opts)}`); - if (opts.ghcup.releaseChannel) { - await core.group(`Preparing ghcup environment`, async () => (0, installer_1.addGhcupReleaseChannel)(opts.ghcup.releaseChannel, os)); + if (opts.ghcup.releaseChannels.length > 0) { + await core.group(`Setting release channels`, async () => { + for (const channel of opts.ghcup.releaseChannels) { + await (0, installer_1.addGhcupReleaseChannel)(channel, os); + } + }); } for (const [t, { resolved }] of Object.entries(opts).filter(o => o[1].enable)) { await core.group(`Preparing ${t} environment`, async () => (0, installer_1.resetTool)(t, resolved, os)); diff --git a/src/installer.ts b/src/installer.ts index 6e7f24f..ca1296b 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -2,6 +2,7 @@ import * as core from '@actions/core'; import {exec as e} from '@actions/exec'; import {which} from '@actions/io'; import * as tc from '@actions/tool-cache'; +import * as child_process from 'child_process'; import {promises as afs} from 'fs'; import {join, dirname} from 'path'; import {ghcup_version, OS, Tool, releaseRevision} from './opts'; @@ -14,7 +15,7 @@ import {compareVersions} from 'compare-versions'; // compareVersions can be used const exec = async (cmd: string, args?: string[]): Promise => e(cmd, args, {ignoreReturnCode: true}); -function failed(tool: Tool, version: string): void { +async function failed(tool: Tool, version: string): Promise { throw new Error(`All install methods for ${tool} ${version} failed`); } @@ -33,7 +34,13 @@ async function configureOutputs( core.setOutput('stack-root', sr); if (os === 'win32') core.exportVariable('STACK_ROOT', sr); } - core.setOutput(`${tool}-version`, version); + + // can't put this in resolve() because it might run before ghcup is installed + let resolvedVersion = version; + if (version === 'latest-nightly') { + resolvedVersion = (await getLatestNightlyFromGhcup(os)) ?? version; + } + core.setOutput(`${tool}-version`, resolvedVersion); } async function success( @@ -165,18 +172,14 @@ export async function installTool( switch (os) { case 'linux': - if (tool === 'ghc' && version === 'head') { - if (!(await aptBuildEssential())) break; - - await ghcupGHCHead(); - break; - } - if (tool === 'ghc' && compareVersions('8.3', version)) { - // Andreas, 2022-12-09: The following errors out if we are not ubuntu-20.04. - // Atm, I do not know how to check whether we are on ubuntu-20.04. - // So, ignore the error. - // if (!(await aptLibCurses5())) break; - await aptLibNCurses5(); + if (tool === 'ghc') { + if (version !== 'latest-nightly' && compareVersions('8.3', version)) { + // Andreas, 2022-12-09: The following errors out if we are not ubuntu-20.04. + // Atm, I do not know how to check whether we are on ubuntu-20.04. + // So, ignore the error. + // if (!(await aptLibCurses5())) break; + await aptLibNCurses5(); + } } await ghcup(tool, version, os); if (await isInstalled(tool, version, os)) return; @@ -244,15 +247,6 @@ async function stack(version: string, os: OS): Promise { await tc.cacheDir(stackPath, 'stack', version); } -async function aptBuildEssential(): Promise { - core.info(`Installing build-essential using apt-get (for ghc-head)`); - - const returnCode = await exec( - `sudo -- sh -c "apt-get update && apt-get -y install build-essential"` - ); - return returnCode === 0; -} - async function aptLibNCurses5(): Promise { core.info( `Installing libcurses5 and libtinfo5 using apt-get (for ghc < 8.3)` @@ -338,6 +332,43 @@ export async function addGhcupReleaseChannel( await exec(bin, ['config', 'add-release-channel', channel.toString()]); } +/** + * Get the resolved version of the `latest-nightly` GHC tag from ghcup, + * e.g. '9.9.20230711' + */ +async function getLatestNightlyFromGhcup(os: OS): Promise { + try { + const ghcupExe = await ghcupBin(os); + /* Example output: + + ghc 9.0.2 base-4.15.1.0 + ghc 9.7.20230526 nightly 2023-06-27 + ghc 9.9.20230711 latest-nightly 2023-07-12 + cabal 2.4.1.0 no-bindist + cabal 3.6.2.0 recommended + */ + const out = await new Promise((resolve, reject) => + child_process.execFile( + ghcupExe, + ['list', '-nr'], + {encoding: 'utf-8'}, + (error, stdout) => (error ? reject(error) : resolve(stdout)) + ) + ); + return ( + out + .split('\n') + .map(line => line.split(' ')) + .filter(line => line[2] === 'latest-nightly') + .map(line => line[1]) + .at(0) ?? null + ); + } catch (error) { + // swallow failures, just return null + return null; + } +} + async function ghcup(tool: Tool, version: string, os: OS): Promise { core.info(`Attempting to install ${tool} ${version} using ghcup`); const bin = await ghcupBin(os); @@ -345,19 +376,6 @@ async function ghcup(tool: Tool, version: string, os: OS): Promise { if (returnCode === 0) await exec(bin, ['set', tool, version]); } -async function ghcupGHCHead(): Promise { - core.info(`Attempting to install ghc head using ghcup`); - const bin = await ghcupBin('linux'); - const returnCode = await exec(bin, [ - 'install', - 'ghc', - '-u', - 'https://gitlab.haskell.org/ghc/ghc/-/jobs/artifacts/master/raw/ghc-x86_64-deb9-linux-integer-simple.tar.xz?job=validate-x86_64-linux-deb9-integer-simple', - 'head' - ]); - if (returnCode === 0) await exec(bin, ['set', 'ghc', 'head']); -} - async function getChocoPath( tool: Tool, version: string, diff --git a/src/main.ts b/src/main.ts index 61a8ddb..16a9b56 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,25 @@ import * as core from '@actions/core'; -import {yamlInputs} from './opts'; import run from './setup-haskell'; -run( - Object.fromEntries(Object.keys(yamlInputs).map(k => [k, core.getInput(k)])) -); +const getToggleInput = (name: string) => core.getInput(name) !== ''; + +const getBooleanInput = (name: string): boolean | undefined => { + // https://github.com/actions/toolkit/issues/844 + if (!process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`]) { + return undefined; + } + return core.getBooleanInput(name); +}; + +run({ + ghcVersion: core.getInput('ghc-version'), + cabalVersion: core.getInput('cabal-version'), + stackVersion: core.getInput('stack-version'), + enableStack: getToggleInput('enable-stack'), + stackNoGlobal: getToggleInput('stack-no-global'), + stackSetupGhc: getToggleInput('stack-setup-ghc'), + cabalUpdate: getBooleanInput('cabal-update'), + ghcupReleaseChannels: core.getMultilineInput('ghcup-release-channels'), + ghcupReleaseChannel: core.getInput('ghcup-release-channel'), + disableMatcher: getToggleInput('disable-matcher') +}); diff --git a/src/opts.ts b/src/opts.ts index 9fe2dc1..aab5548 100644 --- a/src/opts.ts +++ b/src/opts.ts @@ -24,7 +24,7 @@ export interface ProgramOpt { export interface Options { ghc: ProgramOpt; - ghcup: {releaseChannel?: URL}; + ghcup: {releaseChannels: URL[]}; cabal: ProgramOpt & {update: boolean}; stack: ProgramOpt & {setup: boolean}; general: {matcher: {enable: boolean}}; @@ -117,63 +117,52 @@ export function releaseRevision(version: string, tool: Tool, os: OS): string { return result; } -/** - * Convert a string input to a boolean according to the YAML 1.2 "core schema" specification. - * Supported boolean renderings: `true | True | TRUE | false | False | FALSE` . - * ref: https://yaml.org/spec/1.2/spec.html#id2804923 - * Adapted from: https://github.com/actions/toolkit/commit/fbdf27470cdcb52f16755d32082f1fee0bfb7d6d#diff-f63fb32fca85d8e177d6400ce078818a4815b80ac7a3319b60d3507354890992R94-R115 - * - * @param name name of the input - * @param val supposed string representation of a boolean - * @returns boolean - */ -export function parseYAMLBoolean(name: string, val: string): boolean { - const trueValue = ['true', 'True', 'TRUE']; - const falseValue = ['false', 'False', 'FALSE']; - if (trueValue.includes(val)) return true; - if (falseValue.includes(val)) return false; - throw new TypeError( - `Action input "${name}" does not meet YAML 1.2 "Core Schema" specification: \n` + - `Supported boolean values: \`true | True | TRUE | false | False | FALSE\`` - ); -} - -export function parseURL(name: string, val: string): URL | undefined { - if (val === '') return undefined; - try { - return new URL(val); - } catch (e) { - throw new TypeError(`Action input "${name}" is not a valid URL`); - } -} +export type RawInputs = { + ghcVersion?: string; + cabalVersion?: string; + stackVersion?: string; + enableStack?: boolean; + stackNoGlobal?: boolean; + stackSetupGhc?: boolean; + cabalUpdate?: boolean; + ghcupReleaseChannels?: string[]; + ghcupReleaseChannel?: string; + disableMatcher?: boolean; +}; export function getOpts( {ghc, cabal, stack}: Defaults, os: OS, - inputs: Record + inputs: RawInputs ): Options { core.debug(`Inputs are: ${JSON.stringify(inputs)}`); - const stackNoGlobal = (inputs['stack-no-global'] || '') !== ''; - const stackSetupGhc = (inputs['stack-setup-ghc'] || '') !== ''; - const stackEnable = (inputs['enable-stack'] || '') !== ''; - const matcherDisable = (inputs['disable-matcher'] || '') !== ''; - const ghcupReleaseChannel = parseURL( - 'ghcup-release-channel', - inputs['ghcup-release-channel'] || '' - ); - // Andreas, 2023-01-05, issue #29: - // 'cabal-update' has a default value, so we should get a proper boolean always. - // Andreas, 2023-01-06: This is not true if we use the action as a library. - // Thus, need to patch with default value here. - const cabalUpdate = parseYAMLBoolean( - 'cabal-update', - inputs['cabal-update'] || 'true' - ); + + const stackNoGlobal = inputs.stackNoGlobal ?? false; + const stackSetupGhc = inputs.stackSetupGhc ?? false; + const stackEnable = inputs.enableStack ?? false; + const cabalUpdate = inputs.cabalUpdate ?? true; + const matcherDisable = inputs.disableMatcher ?? false; + + if (inputs.ghcupReleaseChannel) { + core.warning( + 'ghcup-release-channel is deprecated in favor of ghcup-release-channels' + ); + inputs.ghcupReleaseChannels = [inputs.ghcupReleaseChannel]; + } + + const ghcupReleaseChannels = (inputs.ghcupReleaseChannels ?? []).map(v => { + try { + return new URL(v); + } catch (e) { + throw new TypeError(`Not a valid URL: ${v}`); + } + }); + core.debug(`${stackNoGlobal}/${stackSetupGhc}/${stackEnable}`); const verInpt = { - ghc: inputs['ghc-version'] || ghc.version, - cabal: inputs['cabal-version'] || cabal.version, - stack: inputs['stack-version'] || stack.version + ghc: inputs.ghcVersion || ghc.version, + cabal: inputs.cabalVersion || cabal.version, + stack: inputs.stackVersion || stack.version }; const errors = []; @@ -204,7 +193,7 @@ export function getOpts( enable: ghcEnable }, ghcup: { - releaseChannel: ghcupReleaseChannel + releaseChannels: ghcupReleaseChannels }, cabal: { raw: verInpt.cabal, diff --git a/src/setup-haskell.ts b/src/setup-haskell.ts index a0502e4..7c7c2c7 100644 --- a/src/setup-haskell.ts +++ b/src/setup-haskell.ts @@ -4,7 +4,7 @@ import ensureError from 'ensure-error'; import * as fs from 'fs'; import * as path from 'path'; import {EOL} from 'os'; -import {getOpts, getDefaults, Tool} from './opts'; +import {RawInputs, getOpts, getDefaults, Tool} from './opts'; import {addGhcupReleaseChannel, installTool, resetTool} from './installer'; import type {OS} from './opts'; import {exec} from '@actions/exec'; @@ -19,9 +19,7 @@ async function cabalConfig(): Promise { return out.toString().trim().split('\n').slice(-1)[0].trim(); } -export default async function run( - inputs: Record -): Promise { +export default async function run(inputs: RawInputs): Promise { try { core.info('Preparing to setup a Haskell environment'); const os = process.platform as OS; @@ -30,10 +28,12 @@ export default async function run( core.debug(`run: os = ${JSON.stringify(os)}`); core.debug(`run: opts = ${JSON.stringify(opts)}`); - if (opts.ghcup.releaseChannel) { - await core.group(`Preparing ghcup environment`, async () => - addGhcupReleaseChannel(opts.ghcup.releaseChannel!, os) - ); + if (opts.ghcup.releaseChannels.length > 0) { + await core.group(`Setting release channels`, async () => { + for (const channel of opts.ghcup.releaseChannels) { + await addGhcupReleaseChannel(channel, os); + } + }); } for (const [t, {resolved}] of Object.entries(opts).filter(