Skip to content

Commit 8f05fe7

Browse files
emberianclaude
authored andcommitted
feat: add apt repo and install script installation methods
Add platform-specific installation paths for Elide: - Debian/Ubuntu: install via the official apt repository (GPG key + source list), with version pinning support - macOS / non-Debian Linux: install via the dl.elide.dev install script - Windows: keep existing zip/tarball download from dist.elide.zip Also fixes several pre-existing issues: - Update download base URL to modern dist.elide.zip scheme - Strip channel prefix from version tags in download URLs - Account for bin/ subdirectory in release archives - Replace unsupported `elide run -c` prewarm with `elide info` - Add --batch --yes to gpg for non-interactive CI use Includes unit tests for all new modules (platform detection, apt installer, script installer, download URL construction) and expands the CI matrix to test on ubuntu, macos, and windows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d91d585 commit 8f05fe7

15 files changed

Lines changed: 585 additions & 51 deletions

.github/workflows/ci.yml

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,12 @@ jobs:
7070
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
7171

7272
test-action:
73-
name: "Test: Actions"
74-
runs-on: ubuntu-latest
73+
name: "Test: Actions (${{ matrix.os }})"
74+
runs-on: ${{ matrix.os }}
75+
strategy:
76+
fail-fast: false
77+
matrix:
78+
os: [ubuntu-latest, macos-latest, windows-latest]
7579

7680
steps:
7781
- name: Harden Runner
@@ -88,9 +92,12 @@ jobs:
8892
uses: ./
8993
with: {}
9094

91-
- name: "Test: Print Output"
92-
id: output
93-
run: echo "${{ steps.test-action.outputs.path }}"
95+
- name: "Test: Verify Installation"
96+
shell: bash
97+
run: |
98+
elide --version
99+
echo "Path: ${{ steps.test-action.outputs.path }}"
100+
echo "Version: ${{ steps.test-action.outputs.version }}"
94101
95102
check-dist:
96103
name: "Test: Dist"

__tests__/install-apt.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import * as exec from '@actions/exec'
2+
import * as io from '@actions/io'
3+
import * as core from '@actions/core'
4+
import * as command from '../src/command'
5+
import { installViaApt } from '../src/install-apt'
6+
import buildOptions from '../src/options'
7+
8+
describe('install-apt', () => {
9+
const execSpy = jest.spyOn(exec, 'exec')
10+
const whichSpy = jest.spyOn(io, 'which')
11+
const obtainVersionSpy = jest.spyOn(command, 'obtainVersion')
12+
13+
beforeEach(() => {
14+
jest.clearAllMocks()
15+
16+
// suppress log output
17+
jest.spyOn(core, 'info').mockImplementation(() => {})
18+
jest.spyOn(core, 'debug').mockImplementation(() => {})
19+
20+
// default mocks: all exec calls succeed
21+
execSpy.mockResolvedValue(0)
22+
whichSpy.mockResolvedValue('/usr/bin/elide')
23+
obtainVersionSpy.mockResolvedValue('1.0.0')
24+
})
25+
26+
it('should run the correct apt commands for amd64', async () => {
27+
const options = buildOptions({
28+
os: 'linux',
29+
arch: 'amd64',
30+
version: 'latest'
31+
})
32+
const result = await installViaApt(options)
33+
34+
// GPG key download
35+
expect(execSpy).toHaveBeenCalledWith('bash', [
36+
'-c',
37+
expect.stringContaining('keys.elide.dev/gpg.key')
38+
])
39+
40+
// apt source with correct arch
41+
expect(execSpy).toHaveBeenCalledWith(
42+
'sudo',
43+
['tee', '/etc/apt/sources.list.d/elide.list'],
44+
expect.objectContaining({
45+
input: expect.any(Buffer)
46+
})
47+
)
48+
49+
// apt-get update
50+
expect(execSpy).toHaveBeenCalledWith('sudo', ['apt-get', 'update', '-qq'])
51+
52+
// apt-get install elide (no version pin for latest)
53+
expect(execSpy).toHaveBeenCalledWith('sudo', [
54+
'apt-get',
55+
'install',
56+
'-y',
57+
'-qq',
58+
'elide'
59+
])
60+
61+
expect(result.elidePath).toBe('/usr/bin/elide')
62+
expect(result.version.tag_name).toBe('1.0.0')
63+
})
64+
65+
it('should map aarch64 to arm64 for apt', async () => {
66+
const options = buildOptions({
67+
os: 'linux',
68+
arch: 'aarch64',
69+
version: 'latest'
70+
})
71+
await installViaApt(options)
72+
73+
// Check that the tee call includes arm64
74+
const teeCall = execSpy.mock.calls.find(
75+
call => call[0] === 'sudo' && call[1]?.[0] === 'tee'
76+
)
77+
expect(teeCall).toBeDefined()
78+
const input = teeCall![2] as { input: Buffer }
79+
expect(input.input.toString()).toContain('arch=arm64')
80+
})
81+
82+
it('should pin a specific version when requested', async () => {
83+
const options = buildOptions({
84+
os: 'linux',
85+
arch: 'amd64',
86+
version: '1.2.3'
87+
})
88+
await installViaApt(options)
89+
90+
expect(execSpy).toHaveBeenCalledWith('sudo', [
91+
'apt-get',
92+
'install',
93+
'-y',
94+
'-qq',
95+
'elide=1.2.3'
96+
])
97+
})
98+
})

__tests__/install-script.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as exec from '@actions/exec'
2+
import * as io from '@actions/io'
3+
import * as core from '@actions/core'
4+
import * as toolCache from '@actions/tool-cache'
5+
import * as command from '../src/command'
6+
import { installViaScript } from '../src/install-script'
7+
import buildOptions from '../src/options'
8+
9+
describe('install-script', () => {
10+
const execSpy = jest.spyOn(exec, 'exec')
11+
const whichSpy = jest.spyOn(io, 'which')
12+
const downloadToolSpy = jest.spyOn(toolCache, 'downloadTool')
13+
const obtainVersionSpy = jest.spyOn(command, 'obtainVersion')
14+
const addPathSpy = jest.spyOn(core, 'addPath')
15+
16+
beforeEach(() => {
17+
jest.clearAllMocks()
18+
19+
// suppress log output
20+
jest.spyOn(core, 'info').mockImplementation(() => {})
21+
jest.spyOn(core, 'debug').mockImplementation(() => {})
22+
23+
// default mocks
24+
downloadToolSpy.mockResolvedValue('/tmp/install.sh')
25+
execSpy.mockResolvedValue(0)
26+
whichSpy.mockResolvedValue('/usr/local/bin/elide')
27+
obtainVersionSpy.mockResolvedValue('1.0.0')
28+
})
29+
30+
it('should download and execute the install script', async () => {
31+
const options = buildOptions({
32+
os: 'darwin',
33+
arch: 'aarch64',
34+
version: 'latest'
35+
})
36+
const result = await installViaScript(options)
37+
38+
expect(downloadToolSpy).toHaveBeenCalledWith(
39+
'https://dl.elide.dev/cli/install.sh'
40+
)
41+
expect(execSpy).toHaveBeenCalledWith('bash', ['/tmp/install.sh'])
42+
expect(result.elidePath).toBe('/usr/local/bin/elide')
43+
expect(result.version.tag_name).toBe('1.0.0')
44+
})
45+
46+
it('should pass --version when a specific version is requested', async () => {
47+
const options = buildOptions({
48+
os: 'linux',
49+
arch: 'amd64',
50+
version: '1.2.3'
51+
})
52+
await installViaScript(options)
53+
54+
expect(execSpy).toHaveBeenCalledWith('bash', [
55+
'/tmp/install.sh',
56+
'--version',
57+
'1.2.3'
58+
])
59+
})
60+
61+
it('should fall back to ~/.elide/bin if which fails initially', async () => {
62+
whichSpy
63+
.mockRejectedValueOnce(new Error('not found'))
64+
.mockResolvedValueOnce('/home/runner/.elide/bin/elide')
65+
66+
const options = buildOptions({
67+
os: 'linux',
68+
arch: 'amd64',
69+
version: 'latest'
70+
})
71+
const result = await installViaScript(options)
72+
73+
expect(addPathSpy).toHaveBeenCalled()
74+
expect(result.elidePath).toBe('/home/runner/.elide/bin/elide')
75+
})
76+
})

__tests__/main.test.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import * as io from '@actions/io'
22
import * as core from '@actions/core'
3+
import * as platform from '../src/platform'
4+
import * as command from '../src/command'
5+
import * as installScript from '../src/install-script'
36
import * as main from '../src/main'
47
import buildOptions, { OptionName } from '../src/options'
58
import { ElideArch, ElideOS } from '../src/releases'
@@ -9,6 +12,15 @@ import { resolveExistingBinary } from '../src/main'
912
// set timeout to 3 minutes to account for downloads
1013
jest.setTimeout(3 * 60 * 1000)
1114

15+
// Mock platform-specific installers. The apt and script installers have
16+
// their own dedicated test suites; here we just need the dispatch to
17+
// succeed without re-running real installations.
18+
jest.spyOn(platform, 'isDebianLike').mockResolvedValue(false)
19+
const scriptSpy = jest.spyOn(installScript, 'installViaScript')
20+
const prewarmSpy = jest.spyOn(command, 'prewarm')
21+
const infoSpy = jest.spyOn(command, 'info')
22+
const obtainVersionSpy = jest.spyOn(command, 'obtainVersion')
23+
1224
// Mock the GitHub Actions core libs
1325
const getInput = jest.spyOn(core, 'getInput')
1426
const setFailed = jest.spyOn(core, 'setFailed')
@@ -40,13 +52,23 @@ const action = jest.spyOn(main, 'run')
4052
describe('action', () => {
4153
beforeEach(() => {
4254
jest.clearAllMocks()
55+
// Re-apply mocks cleared by clearAllMocks
56+
jest.spyOn(platform, 'isDebianLike').mockResolvedValue(false)
57+
scriptSpy.mockResolvedValue({
58+
version: { tag_name: '1.0.0', userProvided: false },
59+
elidePath: '/mock/bin/elide',
60+
elideHome: '/mock',
61+
elideBin: '/mock/bin'
62+
})
63+
prewarmSpy.mockResolvedValue(undefined)
64+
infoSpy.mockResolvedValue(undefined)
65+
obtainVersionSpy.mockResolvedValue('1.0.0')
4366
})
4467

4568
it('reads option inputs', async () => {
4669
setupMocks()
4770
await main.run()
4871
expect(action).toHaveReturned()
49-
expect(action).not.toThrow()
5072
expect(setFailed).not.toHaveBeenCalled()
5173
expect(getInput).toHaveBeenCalledWith(OptionName.VERSION)
5274
expect(getInput).toHaveBeenCalledWith(OptionName.OS)
@@ -67,7 +89,6 @@ describe('action', () => {
6789

6890
await main.run()
6991
expect(action).toHaveReturned()
70-
expect(action).not.toThrow()
7192
expect(setFailed).not.toHaveBeenCalled()
7293
expect(setOutput).toHaveBeenCalledWith(
7394
ActionOutputName.PATH,
@@ -84,10 +105,7 @@ describe('action', () => {
84105
info.mockImplementationOnce(() => {
85106
throw new Error('oh noes')
86107
})
87-
const runner = async () => {
88-
await main.run()
89-
}
90-
expect(runner).not.toThrow()
108+
await main.run()
91109
expect(setFailed).toHaveBeenCalled()
92110
})
93111

@@ -144,7 +162,6 @@ describe('action', () => {
144162
force: true
145163
})
146164
expect(action).toHaveReturned()
147-
expect(action).not.toThrow()
148165
expect(setFailed).not.toHaveBeenCalled()
149166
expect(setOutput).toHaveBeenCalledWith(
150167
ActionOutputName.PATH,
@@ -163,7 +180,6 @@ describe('action', () => {
163180
version: '1.0.0-alpha9'
164181
})
165182
expect(action).toHaveReturned()
166-
expect(action).not.toThrow()
167183
expect(setFailed).not.toHaveBeenCalled()
168184
expect(setOutput).toHaveBeenCalledWith(
169185
ActionOutputName.PATH,

__tests__/platform.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const mockAccess = jest.fn()
2+
jest.mock('node:fs/promises', () => ({
3+
access: mockAccess
4+
}))
5+
6+
import { isDebianLike } from '../src/platform'
7+
8+
describe('platform detection', () => {
9+
beforeEach(() => {
10+
mockAccess.mockReset()
11+
})
12+
13+
it('should return true when /etc/debian_version exists', async () => {
14+
mockAccess.mockResolvedValueOnce(undefined)
15+
expect(await isDebianLike()).toBe(true)
16+
expect(mockAccess).toHaveBeenCalledWith('/etc/debian_version')
17+
})
18+
19+
it('should return false when /etc/debian_version does not exist', async () => {
20+
mockAccess.mockRejectedValueOnce(new Error('ENOENT: no such file'))
21+
expect(await isDebianLike()).toBe(false)
22+
})
23+
})

0 commit comments

Comments
 (0)