Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolve min as the earliest compatible Julia version (compatible with the user's project) #202

Merged
merged 34 commits into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f6c0c51
Support the special version "MIN"
omus Jan 5, 2024
4467bdb
fixup! Support the special version "MIN"
omus Jan 5, 2024
a01d66b
fixup! Support the special version "MIN"
omus Jan 5, 2024
ba08cda
Build
omus Jan 5, 2024
c551d5c
Support JULIA_PROJECT
omus Jan 5, 2024
319c0e6
WIP
omus Jan 8, 2024
bc76ff4
Add tests
omus Jan 8, 2024
0c43806
Rename special version to project-min
omus Jan 8, 2024
20471b9
Add forgotten test fixtures
omus Jan 8, 2024
ccc4e23
Run build
omus Jan 8, 2024
03c056a
Get latest prerelease/release
omus Jan 8, 2024
b69821d
No special pre-release behaviour
omus Jan 9, 2024
e371afc
Sticking with MIN for job matrix usage
omus Jan 9, 2024
304b120
build
omus Jan 9, 2024
5dfcf28
Add test for NPM semver difference
omus Jan 9, 2024
b8106f2
Cleanup
omus Jan 9, 2024
10a9b2f
build
omus Jan 9, 2024
02aa933
Addition tests
omus Jan 9, 2024
d897c08
Define validJuliaRange
omus Jan 9, 2024
ac7c4d5
Robust test suite
omus Jan 9, 2024
70ba35f
Disallow less-than-equal
omus Jan 9, 2024
266d809
npm run build
omus Jan 9, 2024
0718bbf
Refactor validJuliaCompatRange to return a validRange
omus Jan 10, 2024
f13a394
Build
omus Jan 10, 2024
8a8dfd5
Merge branch 'master' into cv/min-version
omus Aug 30, 2024
df5b495
Rename MIN to min
omus Aug 30, 2024
e5a91a7
fixup! Merge branch 'master' into cv/min-version
omus Aug 30, 2024
1170aba
Rename getProjectFile to getProjectFilePath
omus Aug 30, 2024
1259c4f
Comment on "project" input
omus Aug 30, 2024
ed73f04
Additional tests for getProjectFilePath
omus Aug 30, 2024
ecaeb80
Add comment on `juliaCompatRange`
omus Aug 30, 2024
b38942a
Build package
omus Aug 30, 2024
1665281
Update dependencies
omus Aug 30, 2024
34392a0
Merge branch 'master' into cv/min-version
omus Aug 30, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ This action sets up a Julia environment for use in actions by downloading a spec
#
# Default: false
show-versioninfo: ''

# Set the path to the project directory or file to use when resolving some versions (e.g. MIN).
#
# Defaults to using JULIA_PROJECT if defined, otherwise '.'
project: ''
```

### Outputs
Expand Down Expand Up @@ -111,6 +116,7 @@ You can either specify specific Julia versions or version ranges. If you specify
- `~1.3.0-0` is a **tilde** version range that includes _all_ pre-releases of `1.3.0`. It matches all versions `≥ 1.3.0-` and `< 1.4.0`.
- `nightly` will install the latest nightly build.
- `1.7-nightly` will install the latest nightly build for the upcoming 1.7 release. This version will only be available during certain phases of the Julia release cycle.
- `MIN` will install the earliest supported version of Julia compatible with the project. Especially useful in monorepos.

Internally the action uses node's semver package to resolve version ranges. Its [documentation](https://github.com/npm/node-semver#advanced-range-syntax) contains more details on the version range syntax. You can test what version will be selected for a given input in this JavaScript [REPL](https://repl.it/@SaschaMann/setup-julia-version-logic).

Expand Down
Empty file.
Empty file.
Empty file.
Empty file.
56 changes: 56 additions & 0 deletions __tests__/installer.test.ts
DilumAluthge marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,45 @@ process.env['RUNNER_TEMP'] = tempDir

import * as installer from '../src/installer'

describe("getProjectFile tests", () => {
it("Can determine project file is missing", () => {
expect(() => installer.getProjectFile("DNE.toml")).toThrow("Unable to locate project file")
expect(() => installer.getProjectFile(fixtureDir)).toThrow("Unable to locate project file")
})

it('Can determine project file from a directory', () => {
expect(installer.getProjectFile(path.join(fixtureDir, "PkgA"))).toEqual(path.join(fixtureDir, "PkgA", "Project.toml"))
expect(installer.getProjectFile(path.join(fixtureDir, "PkgB"))).toEqual(path.join(fixtureDir, "PkgB", "JuliaProject.toml"))
})

it("Prefers using JuliaProject.toml over Project.toml", () => {
expect(installer.getProjectFile(path.join(fixtureDir, "PkgC"))).toEqual(path.join(fixtureDir, "PkgC", "JuliaProject.toml"))
})
})

describe("readJuliaCompatVersions tests", () => {
it('Can determine Julia compat entries', () => {
// Note: Julia's Pkg.jl uses caret as the default specifier (e.g. `1.2.3 == ^1.2.3`) where as
// NPM's semver uses tilde as the default specifier (e.g. `1.2.3 == 1.2.x == ~1.2.3`). In order
// to ensure the Julia default is respected we'll be sure to add the caret specify where needed.
// https://pkgdocs.julialang.org/v1/compatibility/#Version-specifier-format
// https://github.com/npm/node-semver#x-ranges-12x-1x-12-
const toml = '[compat]\njulia = "1, >=1.1, ^1.2, ~1.3"'
expect(installer.readJuliaCompatVersions(toml)).toEqual(["^1", ">=1.1", "^1.2", "~1.3"])
})

it('Handle whitespace', () => {
const toml = '[compat]\njulia = " 1,2 , 3 ,"'
expect(installer.readJuliaCompatVersions(toml)).toEqual(["^1", "^2", "^3"])
})

it('Handle missing compat entries', () => {
expect(installer.readJuliaCompatVersions("")).toEqual([])
expect(installer.readJuliaCompatVersions("[compat]")).toEqual([])
expect(installer.readJuliaCompatVersions("[compat]\njulia = \"\"")).toEqual([])
})
})

describe('version matching tests', () => {
describe('specific versions', () => {
it('Doesn\'t change the version when given a valid semver version', () => {
Expand Down Expand Up @@ -84,6 +123,23 @@ describe('version matching tests', () => {
})
})
})

describe('julia compat versions', () => {
it('Understands MIN', () => {
let versions = ["1.6.7", "1.7.1-rc1", "1.7.1-rc2", "1.7.1", "1.7.2", "1.8.0"]
expect(installer.getJuliaVersion(versions, "MIN", false, ["^1.7"])).toEqual("1.7.1")
expect(installer.getJuliaVersion(versions, "MIN", true, ["^1.7"])).toEqual("1.7.1-rc1")

versions = ["1.6.7", "1.7.3-rc1", "1.7.3-rc2", "1.8.0"]
expect(installer.getJuliaVersion(versions, "MIN", false, ["^1.7"])).toEqual("1.8.0")
expect(installer.getJuliaVersion(versions, "MIN", true, ["^1.7"])).toEqual("1.7.3-rc1")

// NPM's semver package treats "1.7" as "~1.7" instead of "^1.7" like Julia
expect(() => installer.getJuliaVersion(versions, "MIN", false, ["1.7"])).toThrow("Could not find a Julia version that matches")

expect(() => installer.getJuliaVersion(versions, "MIN", true, [])).toThrow("Julia project file does not specify a compat for Julia")
})
})
})

describe('installer tests', () => {
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ inputs:
description: 'Display InteractiveUtils.versioninfo() after installing'
required: false
default: 'false'
project:
omus marked this conversation as resolved.
Show resolved Hide resolved
description: 'The path to the project directory or file to use when resolving some versions (e.g. MIN)'
required: false
default: '' # Special value which fallsback to using JULIA_PROJECT if defined, otherwise "."
outputs:
julia-version:
description: 'The installed Julia version. May vary from the version input if a version range was given as input.'
Expand Down
62 changes: 56 additions & 6 deletions lib/installer.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion lib/setup-julia.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 12 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"@actions/io": "^1.1.3",
"@actions/tool-cache": "^2.0.1",
"async-retry": "^1.3.3",
"semver": "^7.5.4"
"semver": "^7.5.4",
"toml": "^3.0.0"
},
"devDependencies": {
"@types/async-retry": "^1.4.8",
Expand Down
63 changes: 58 additions & 5 deletions src/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as path from 'path'
import retry = require('async-retry')

import * as semver from 'semver'
import * as toml from 'toml'

// Translations between actions input and Julia arch names
const osMap = {
Expand Down Expand Up @@ -76,15 +77,67 @@ export async function getJuliaVersions(versionInfo): Promise<string[]> {
return versions
}

export function getJuliaVersion(availableReleases: string[], versionInput: string, includePrerelease: boolean = false): string {
export function getProjectFile(projectInput: string = ""): string {
omus marked this conversation as resolved.
Show resolved Hide resolved
let projectFile: string = ""

// Default value for projectInput
if (!projectInput) {
projectInput = process.env.JULIA_PROJECT || "."
omus marked this conversation as resolved.
Show resolved Hide resolved
}

if (fs.existsSync(projectInput) && fs.lstatSync(projectInput).isFile()) {
projectFile = projectInput
} else {
for (let projectFilename of ["JuliaProject.toml", "Project.toml"]) {
let p = path.join(projectInput, projectFilename)
if (fs.existsSync(p) && fs.lstatSync(p).isFile()) {
projectFile = p
break
}
}
}

if (!projectFile) {
throw new Error(`Unable to locate project file with project input: ${projectInput}`)
}

return projectFile
}

/**
* @returns An array of version ranges compatible with the Julia project
*/
export function readJuliaCompatVersions(projectFileContent: string): string[] {
let compatVersions: string[] = []

let meta = toml.parse(projectFileContent)
for (let versionRange of meta.compat?.julia?.split(",") || []) {
compatVersions.push(versionRange.trim().replace(/^(?=\d)/, "^"))
}

return compatVersions.filter(v => v)
}

export function getJuliaVersion(availableReleases: string[], versionInput: string, includePrerelease: boolean = false, juliaCompatVersions: string[] = []): string {
let version: string | null

if (semver.valid(versionInput) == versionInput || versionInput.endsWith('nightly')) {
// versionInput is a valid version or a nightly version, use it directly
return versionInput
version = versionInput
} else if (versionInput == "MIN") {
// Resolve "MIN" to the minimum supported Julia version compatible with the project file
if (!juliaCompatVersions.length) {
throw new Error('Unable to use version "MIN" when the Julia project file does not specify a compat for Julia')
}
let minVersions = juliaCompatVersions.map(v => semver.minSatisfying(availableReleases, v, {includePrerelease}))
version = semver.sort(minVersions.filter((v): v is string => v !== null))[0]
console.log(`availableReleases: ${availableReleases}\njuliaCompatVersions: ${juliaCompatVersions}\nversion: ${version}`)
} else {
// Use the highest available version that matches versionInput
version = semver.maxSatisfying(availableReleases, versionInput, {includePrerelease})
}

// Use the highest available version that matches versionInput
let version = semver.maxSatisfying(availableReleases, versionInput, {includePrerelease})
if (version == null) {
if (!version) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the difference between (!version) (this PR) and if (version == null) (on master)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the issue with the original code is that null == undefined is also true so the original statement is kind of misleading. I switched things to be based on "truthy"-ness so now we thrown an exception on any value that is coerced into a false such as null, undefined, or "".

throw new Error(`Could not find a Julia version that matches ${versionInput}`)
}

Expand Down
10 changes: 9 additions & 1 deletion src/setup-julia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ async function run() {
const versionInput = core.getInput('version')
const includePrereleases = core.getInput('include-all-prereleases') == 'true'
const originalArchInput = core.getInput('arch')
const projectInput = core.getInput('project')

// It can easily happen that, for example, a workflow file contains an input `version: ${{ matrix.julia-version }}`
// while the strategy matrix only contains a key `${{ matrix.version }}`.
Expand All @@ -57,9 +58,16 @@ async function run() {

const arch = archSynonyms[originalArchInput]

// Determine the Julia compat ranges as specified by the Project.toml only for special versions that require them.
let juliaCompatVersions: Array<string> = [];
if (versionInput === "MIN") {
const projectFile = installer.getProjectFile(projectInput)
juliaCompatVersions = installer.readJuliaCompatVersions(fs.readFileSync(projectFile).toString())
}

const versionInfo = await installer.getJuliaVersionInfo()
const availableReleases = await installer.getJuliaVersions(versionInfo)
const version = installer.getJuliaVersion(availableReleases, versionInput, includePrereleases)
const version = installer.getJuliaVersion(availableReleases, versionInput, includePrereleases, juliaCompatVersions)
core.debug(`selected Julia version: ${arch}/${version}`)
core.setOutput('julia-version', version)

Expand Down