Skip to content

Commit 785fa68

Browse files
feat: support Julia versions from .tool-versions
1 parent fa02766 commit 785fa68

5 files changed

Lines changed: 182 additions & 9 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ This action sets up a Julia environment for use in actions by downloading a spec
4545
# Default: '1'
4646
version: '1'
4747

48+
# Read the Julia version from a .tool-versions file.
49+
#
50+
# This is useful for repositories that already pin Julia with asdf or mise-en-place.
51+
# The file must contain a Julia entry with major, minor, and patch versions, for example:
52+
# julia 1.10.11
53+
#
54+
# The `version-file` input cannot be used together with the `version` input.
55+
#
56+
# Default: ''
57+
version-file: '.tool-versions'
58+
4859
# The architecture of the Julia binaries.
4960
#
5061
# Please note that installing aarch64 binaries only makes sense on self-hosted aarch64 runners.
@@ -128,6 +139,24 @@ You can either specify specific Julia versions or version ranges. If you specify
128139
>
129140
> It is strongly recommended to wrap versions in quotes. Otherwise, the YAML parser used by GitHub Actions parses certain versions as numbers which causes the wrong version to be selected. For example, `1.0` may be parsed as `1`.
130141

142+
Alternatively, set `version-file` to a `.tool-versions` file containing a Julia entry:
143+
144+
```yaml
145+
steps:
146+
- uses: actions/checkout@v6
147+
- uses: julia-actions/setup-julia@v3
148+
with:
149+
version-file: '.tool-versions'
150+
```
151+
152+
For example, the `.tool-versions` file might contain:
153+
154+
```text
155+
julia 1.10.11
156+
```
157+
158+
The Julia entry must specify major, minor, and patch versions. The `version` and `version-file` inputs cannot be used together.
159+
131160
#### Examples
132161

133162
- `'1.2.0'` is a valid semver version. The action will try to download exactly this version. If it's not available, the build step will fail.

__tests__/installer.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// The testing setup has been derived from the actions/setup-go@bc6edb5 action.
22
// Check README.md for licence information.
33

4+
import * as fs from 'fs'
5+
import * as os from 'os'
46
import * as path from 'path'
57

68
import * as io from '@actions/io'
@@ -40,6 +42,85 @@ process.env['RUNNER_TEMP'] = tempDir
4042
import * as installer from '../src/installer'
4143
import exp from 'constants'
4244

45+
describe("readJuliaVersionFromToolVersionsFile tests", () => {
46+
let tempDirForToolVersions: string
47+
48+
beforeEach(() => {
49+
tempDirForToolVersions = fs.mkdtempSync(path.join(os.tmpdir(), "setup-julia-tool-versions-"))
50+
})
51+
52+
afterEach(() => {
53+
fs.rmSync(tempDirForToolVersions, { force: true, recursive: true })
54+
})
55+
56+
function writeToolVersions(content: string): string {
57+
const versionFilePath = path.join(tempDirForToolVersions, ".tool-versions")
58+
fs.writeFileSync(versionFilePath, content)
59+
return versionFilePath
60+
}
61+
62+
it("Reads a Julia version", () => {
63+
const versionFilePath = writeToolVersions("julia 1.10.4\n")
64+
expect(installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toEqual("1.10.4")
65+
})
66+
67+
it("Handles comments and blank lines", () => {
68+
const versionFilePath = writeToolVersions("\n# tools\n\njulia 1.11.0\n")
69+
expect(installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toEqual("1.11.0")
70+
})
71+
72+
it("Finds Julia among multiple tools", () => {
73+
const versionFilePath = writeToolVersions("nodejs 24.11.1\njulia 1.10.7\npython 3.14.0\n")
74+
expect(installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toEqual("1.10.7")
75+
})
76+
77+
it("Uses the first Julia version token", () => {
78+
const versionFilePath = writeToolVersions("julia 1.10.4 1.11.1 # fallback\n")
79+
expect(installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toEqual("1.10.4")
80+
})
81+
82+
it("Reads a prerelease Julia version", () => {
83+
const versionFilePath = writeToolVersions("julia 1.11.0-rc1\n")
84+
expect(installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toEqual("1.11.0-rc1")
85+
})
86+
87+
it("Throws when the Julia entry only specifies major and minor", () => {
88+
const versionFilePath = writeToolVersions("julia 1.10\n")
89+
expect(() => installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toThrow("must specify major, minor, and patch")
90+
})
91+
92+
it("Throws when the Julia entry only specifies major", () => {
93+
const versionFilePath = writeToolVersions("julia 1\n")
94+
expect(() => installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toThrow("must specify major, minor, and patch")
95+
})
96+
97+
it("Throws when the Julia entry uses a version keyword", () => {
98+
const versionFilePath = writeToolVersions("julia lts\n")
99+
expect(() => installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toThrow("must specify major, minor, and patch")
100+
})
101+
102+
it("Throws when no Julia entry exists", () => {
103+
const versionFilePath = writeToolVersions("nodejs 24.11.1\npython 3.14.0\n")
104+
expect(() => installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toThrow("No Julia version found")
105+
})
106+
107+
it("Throws when the Julia entry has no version", () => {
108+
const versionFilePath = writeToolVersions("julia\n")
109+
expect(() => installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toThrow("No Julia version found")
110+
})
111+
112+
it("Throws when the version file is not named .tool-versions", () => {
113+
const versionFilePath = path.join(tempDirForToolVersions, "julia-version")
114+
fs.writeFileSync(versionFilePath, "julia 1.10.4\n")
115+
expect(() => installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toThrow("only supports .tool-versions")
116+
})
117+
118+
it("Throws when the version file does not exist", () => {
119+
const versionFilePath = path.join(tempDirForToolVersions, ".tool-versions")
120+
expect(() => installer.readJuliaVersionFromToolVersionsFile(versionFilePath)).toThrow("does not exist")
121+
})
122+
})
123+
43124
describe("getProjectFilePath tests", () => {
44125
let orgJuliaProject
45126
let orgWorkingDir

action.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ description: 'Setup a Julia environment and add it to the PATH'
33
author: 'Sascha Mann'
44
inputs:
55
version:
6-
description: 'The Julia version to download (if necessary) and use. Use a string input to avoid unwanted decimal conversion e.g. 1.10 without quotes will be interpreted as 1.1. Examples: "1", "1.10", "lts", "pre"'
7-
default: '1'
6+
description: 'The Julia version to download (if necessary) and use. Use a string input to avoid unwanted decimal conversion e.g. 1.10 without quotes will be interpreted as 1.1. Examples: "1", "1.10", "lts", "pre". Defaults to "1" when neither version nor version-file is set.'
7+
required: false
8+
version-file:
9+
description: 'Path to a .tool-versions file containing the Julia version to download (if necessary) and use. Cannot be used simultaneously with the `version` input.'
10+
required: false
811
include-all-prereleases:
912
description: 'Include prereleases when matching the Julia version to available versions.'
1013
required: false

src/installer.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,45 @@ export async function getJuliaVersions(versionInfo): Promise<string[]> {
8080
return versions
8181
}
8282

83+
/**
84+
* @returns The Julia version specified in an asdf/mise-en-place .tool-versions file.
85+
*/
86+
export function readJuliaVersionFromToolVersionsFile(versionFilePath: string): string {
87+
if (path.basename(versionFilePath) !== ".tool-versions") {
88+
throw new Error(`The version-file input only supports .tool-versions files: ${versionFilePath}`)
89+
}
90+
91+
if (!fs.existsSync(versionFilePath)) {
92+
throw new Error(`The specified version-file does not exist: ${versionFilePath}`)
93+
}
94+
95+
const lines = fs.readFileSync(versionFilePath, 'utf8').split(/\r\n|\r|\n/)
96+
for (let line of lines) {
97+
line = line.trim()
98+
99+
if (!line || line.startsWith("#")) {
100+
continue
101+
}
102+
103+
const match = line.match(/^julia(?:\s+(.+))?$/)
104+
if (!match) {
105+
continue
106+
}
107+
108+
const version = match[1]?.trim().split(/\s+/)[0] || ""
109+
if (!version || version.startsWith("#")) {
110+
throw new Error(`No Julia version found in ${versionFilePath}`)
111+
}
112+
if (!semver.valid(version)) {
113+
throw new Error(`The Julia version in ${versionFilePath} must specify major, minor, and patch versions: ${version}`)
114+
}
115+
116+
return version
117+
}
118+
119+
throw new Error(`No Julia version found in ${versionFilePath}`)
120+
}
121+
83122
/**
84123
* @returns The path to the Julia project file
85124
*/

src/setup-julia.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,20 +43,41 @@ async function run() {
4343

4444
// Inputs.
4545
// Note that we intentionally strip leading and lagging whitespace by using `.trim()`
46-
const versionInput = core.getInput('version').trim()
46+
const rawVersionInput = core.getInput('version').trim()
47+
const versionFileInput = core.getInput('version-file').trim()
4748
const includePrereleases = core.getInput('include-all-prereleases').trim() == 'true'
4849
const originalArchInput = core.getInput('arch').trim()
4950
const forceArch = core.getInput('force-arch').trim() == 'true'
5051
const projectInput = core.getInput('project').trim() // Julia project file
5152

52-
// It can easily happen that, for example, a workflow file contains an input `version: ${{ matrix.julia-version }}`
53-
// while the strategy matrix only contains a key `${{ matrix.version }}`.
54-
// In that case, we want the action to fail, rather than trying to download julia from an URL that's missing parts and 404ing.
55-
// We _could_ fall back to the default but that means that builds silently do things differently than they're meant to, which
56-
// is worse than failing the build.
57-
if (!versionInput) { // if `versionInput` is an empty string
53+
// `core.getInput('version')` returns an empty string both when the input is
54+
// omitted and when it is explicitly set to an empty value. Those cases now
55+
// differ: omitted means use `version-file` or the default `1`, while an
56+
// explicitly empty value usually indicates a typo like
57+
// `version: ${{ matrix.julia-version }}` with no matching matrix key.
58+
// GitHub exposes provided inputs as `INPUT_*` environment variables, so
59+
// `INPUT_VERSION` lets us preserve the old explicit-empty error.
60+
const versionInputWasProvided = process.env.INPUT_VERSION !== undefined
61+
if (versionInputWasProvided && !rawVersionInput) { // if `rawVersionInput` is an empty string
5862
throw new Error('Version input must not be null')
5963
}
64+
if (rawVersionInput && versionFileInput) {
65+
throw new Error('The "version" and "version-file" inputs cannot both be set')
66+
}
67+
68+
let versionInput = rawVersionInput
69+
if (!versionInput && versionFileInput) {
70+
// GitHub Actions does not apply a step-specific working directory to `uses:` steps,
71+
// so relative `version-file` paths are resolved from the checked-out workspace.
72+
const workspace = process.env.GITHUB_WORKSPACE || process.cwd()
73+
const versionFilePath = path.isAbsolute(versionFileInput) ? versionFileInput : path.join(workspace, versionFileInput)
74+
versionInput = installer.readJuliaVersionFromToolVersionsFile(versionFilePath)
75+
core.info(`Resolved ${versionFileInput} as ${versionInput}`)
76+
}
77+
if (!versionInput) {
78+
versionInput = '1'
79+
}
80+
6081
if (versionInput == '1.6') {
6182
core.notice('[setup-julia] If you are testing 1.6 as a Long Term Support (lts) version, consider using the new "lts" version specifier instead of "1.6" explicitly, which will automatically resolve the current lts.')
6283
}

0 commit comments

Comments
 (0)