diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39fb442..1fbfcfb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,11 +120,6 @@ jobs: # runner: macos-latest # installer: pkg - # --- macOS AMD64 --- - - name: macos-amd64 (archive) - runner: macos-15-intel - installer: archive - # --- Windows AMD64 --- - name: windows-amd64 (archive) runner: windows-latest diff --git a/README.md b/README.md index 6402efc..2dbd7a1 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ This installs the latest nightly build of Elide and adds it to the `PATH`. ## Installers -The `installer` input controls how Elide is installed. The default (`archive`) downloads a prebuilt archive from the Elide CDN and caches it using the GitHub Actions tool cache. +The `installer` input controls how Elide is installed. The default (`archive`) downloads a prebuilt archive from the Elide CDN (or, for pinned nightly/build-metadata versions, the matching GitHub release asset) and caches it using the GitHub Actions tool cache. | Installer | Platforms | Description | |---|---|---| diff --git a/__tests__/install-apt.test.ts b/__tests__/install-apt.test.ts index 0d1ac86..d30c34b 100644 --- a/__tests__/install-apt.test.ts +++ b/__tests__/install-apt.test.ts @@ -86,7 +86,7 @@ describe('install-apt', () => { ]) expect(result.elidePath).toBe('/usr/bin/elide') - expect(result.version.tag_name).toBe('1.0.0') + expect(result.version.tag_name).toBe('latest') }) it('should map aarch64 to arm64 for apt', async () => { diff --git a/__tests__/install-msi.test.ts b/__tests__/install-msi.test.ts index 8704bcd..355bc03 100644 --- a/__tests__/install-msi.test.ts +++ b/__tests__/install-msi.test.ts @@ -117,7 +117,7 @@ describe('install-msi', () => { const result = await installViaMsi(options) expect(result.elidePath).toBe('C:\\Elide\\bin\\elide.exe') - expect(result.version.tag_name).toBe('1.0.0') + expect(result.version.tag_name).toBe('latest') expect(result.version.userProvided).toBe(false) }) diff --git a/__tests__/install-pkg.test.ts b/__tests__/install-pkg.test.ts index 71cdaf7..6fe92d5 100644 --- a/__tests__/install-pkg.test.ts +++ b/__tests__/install-pkg.test.ts @@ -117,7 +117,7 @@ describe('install-pkg', () => { const result = await installViaPkg(options) expect(result.elidePath).toBe('/usr/local/bin/elide') - expect(result.version.tag_name).toBe('1.0.0') + expect(result.version.tag_name).toBe('latest') expect(result.elideBin).toBe('/usr/local/bin') expect(result.elideHome).toBe('/usr/local/bin') }) diff --git a/__tests__/install-rpm.test.ts b/__tests__/install-rpm.test.ts index 976e574..dd3bd9f 100644 --- a/__tests__/install-rpm.test.ts +++ b/__tests__/install-rpm.test.ts @@ -109,7 +109,7 @@ describe('install-rpm', () => { '/tmp/elide.rpm' ]) expect(result.elidePath).toBe('/usr/bin/elide') - expect(result.version.tag_name).toBe('1.0.0') + expect(result.version.tag_name).toBe('latest') }) it('should fall back to rpm when dnf is not available', async () => { @@ -137,7 +137,7 @@ describe('install-rpm', () => { expect.arrayContaining(['dnf']) ) expect(result.elidePath).toBe('/usr/bin/elide') - expect(result.version.tag_name).toBe('1.0.0') + expect(result.version.tag_name).toBe('latest') }) it('should return correct release info', async () => { @@ -155,7 +155,7 @@ describe('install-rpm', () => { expect(result.elidePath).toBe('/usr/bin/elide') expect(result.elideBin).toBe('/usr/bin') expect(result.elideHome).toBe('/usr/bin') - expect(result.version.tag_name).toBe('1.0.0') + expect(result.version.tag_name).toBe('1.2.3') expect(result.version.userProvided).toBe(true) }) }) diff --git a/__tests__/install-shell.test.ts b/__tests__/install-shell.test.ts index 542dacf..71bfc38 100644 --- a/__tests__/install-shell.test.ts +++ b/__tests__/install-shell.test.ts @@ -80,7 +80,7 @@ describe('install-shell', () => { '--gha' ]) expect(result.elidePath).toBe('/usr/local/bin/elide') - expect(result.version.tag_name).toBe('1.0.0') + expect(result.version.tag_name).toBe('latest') }) it('should pass --version when a specific version is requested', async () => { @@ -137,7 +137,7 @@ describe('install-shell', () => { '-Gha' ]) expect(result.elidePath).toBe('/usr/local/bin/elide') - expect(result.version.tag_name).toBe('1.0.0') + expect(result.version.tag_name).toBe('latest') }) it('should pass -Version when a specific version is requested', async () => { diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index c094f9f..cd31818 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -85,6 +85,7 @@ mock.module('../src/telemetry', () => ({ })) const main = await import('../src/main') +const { versionMatchesTag } = main const { default: buildOptions, OptionName } = await import('../src/options') const { ElideArch, ElideOS } = await import('../src/releases') const { ActionOutputName } = await import('../src/main') @@ -105,6 +106,53 @@ const setupMocks = () => { setFailedMock.mockImplementation(() => {}) } +describe('versionMatchesTag', () => { + it('exact match', () => { + expect(versionMatchesTag('1.0.0', '1.0.0')).toBe(true) + }) + + it('exact match with prerelease tag', () => { + expect(versionMatchesTag('1.0.0-beta10', '1.0.0-beta10')).toBe(true) + }) + + it('exact match with build-metadata tag', () => { + expect(versionMatchesTag('1.4.0+20260707', '1.4.0+20260707')).toBe(true) + }) + + it('commit-hash suffix on plain semver tag', () => { + expect(versionMatchesTag('1.0.0.6f7ffa7', '1.0.0')).toBe(true) + }) + + it('commit-hash suffix on prerelease tag', () => { + expect(versionMatchesTag('1.0.0-beta10.6f7ffa7', '1.0.0-beta10')).toBe(true) + }) + + it('commit-hash suffix on build-metadata tag', () => { + expect(versionMatchesTag('1.4.0+20260707.6f7ffa7', '1.4.0+20260707')).toBe( + true + ) + }) + + it('different version — no match', () => { + expect(versionMatchesTag('9.9.9', '1.0.0')).toBe(false) + }) + + it('binary ahead of requested prerelease — no match', () => { + expect(versionMatchesTag('1.0.0', '1.0.0-beta10')).toBe(false) + }) + + it('tag is not a prefix without dot separator — no match', () => { + // "1.0.01" must not match tag "1.0.0" (no dot between them) + expect(versionMatchesTag('1.0.01', '1.0.0')).toBe(false) + }) + + it('binary build date does not match tag build date — no match', () => { + expect(versionMatchesTag('1.4.0+20260708.6f7ffa7', '1.4.0+20260707')).toBe( + false + ) + }) +}) + describe('action', () => { beforeEach(() => { execMock.mockClear() @@ -436,7 +484,7 @@ describe('action', () => { it('should warn on version mismatch', async () => { setupMocks() obtainVersionMock.mockResolvedValueOnce('1.0.0').mockResolvedValue('9.9.9') - await main.run({ force: true, installer: 'shell' }) + await main.run({ force: true, installer: 'shell', version: '1.0.0' }) expect(warningMock).toHaveBeenCalledWith( expect.stringContaining('Elide version mismatch'), expect.objectContaining({ title: 'Version Mismatch' }) diff --git a/__tests__/releases-download.test.ts b/__tests__/releases-download.test.ts index 468abc4..3232960 100644 --- a/__tests__/releases-download.test.ts +++ b/__tests__/releases-download.test.ts @@ -56,8 +56,12 @@ mock.module('../src/command', () => ({ elideInfo: jest.fn() })) -const { downloadRelease, resolveLatestVersion, toSemverCacheKey } = - await import('../src/releases') +const { + downloadRelease, + resolveLatestVersion, + resolveVersionByTag, + toSemverCacheKey +} = await import('../src/releases') const { default: buildOptions } = await import('../src/options') describe('resolveLatestVersion', () => { @@ -115,6 +119,91 @@ describe('resolveLatestVersion', () => { }) }) +// resolveVersionByTag's retry loop sleeps for real between attempts; run the +// callback with setTimeout firing immediately so retry tests don't pay that +// delay in wall-clock time. +async function withInstantRetryDelay(fn: () => Promise): Promise { + const realSetTimeout = globalThis.setTimeout + globalThis.setTimeout = ((cb: () => void) => { + cb() + return 0 as unknown as ReturnType + }) as typeof setTimeout + try { + return await fn() + } finally { + globalThis.setTimeout = realSetTimeout + } +} + +describe('resolveVersionByTag', () => { + beforeEach(() => { + requestMock.mockClear() + getOctokitMock.mockClear() + warningMock.mockClear() + debugMock.mockClear() + }) + + it('should resolve a tag with matching assets', async () => { + requestMock.mockResolvedValue({ + data: { + tag_name: '1.4.1+20260716', + name: 'Elide 1.4.1+20260716', + assets: [ + { + name: 'elide.linux-amd64.tgz', + browser_download_url: + 'https://github.com/elide-dev/elide/releases/download/1.4.1%2B20260716/elide.linux-amd64.tgz' + } + ] + } + }) + const result = await resolveVersionByTag('1.4.1+20260716', 'ghp_test') + expect(result.tag_name).toBe('1.4.1+20260716') + expect(result.userProvided).toBe(true) + expect(result.assets).toHaveLength(1) + expect(result.assets?.[0].name).toBe('elide.linux-amd64.tgz') + }) + + it('should warn when no token is provided', async () => { + requestMock.mockResolvedValue({ data: { tag_name: 'x', assets: [] } }) + await resolveVersionByTag('nightly-20260328') + expect(warningMock).toHaveBeenCalledWith( + expect.stringContaining('No GitHub token provided') + ) + }) + + it('should retry on transient failure and succeed', async () => { + requestMock + .mockRejectedValueOnce(new Error('rate limit exceeded')) + .mockResolvedValueOnce({ + data: { tag_name: 'nightly-20260328', assets: [] } + }) + const result = await withInstantRetryDelay(() => + resolveVersionByTag('nightly-20260328') + ) + expect(result.tag_name).toBe('nightly-20260328') + expect(requestMock).toHaveBeenCalledTimes(2) + }) + + it('should fall back immediately on a definitive not-found, without throwing', async () => { + requestMock.mockRejectedValue( + Object.assign(new Error('Not Found'), { status: 404 }) + ) + const result = await resolveVersionByTag('1.4.1+20260716') + expect(result).toEqual({ tag_name: '1.4.1+20260716', userProvided: true }) + expect(requestMock).toHaveBeenCalledTimes(1) + }) + + it('should fall back after exhausting retries on transient errors, without throwing', async () => { + requestMock.mockRejectedValue(new Error('quota exhausted')) + const result = await withInstantRetryDelay(() => + resolveVersionByTag('1.4.1+20260716') + ) + expect(result).toEqual({ tag_name: '1.4.1+20260716', userProvided: true }) + expect(requestMock).toHaveBeenCalledTimes(3) + }) +}) + describe('toSemverCacheKey', () => { it('should pass through valid semver', () => { expect(toSemverCacheKey('1.0.0')).toBe('1.0.0') @@ -190,6 +279,109 @@ describe('downloadRelease', () => { expect(result.version.userProvided).toBe(true) }) + it('should resolve a build-metadata pin via GitHub API and prefer the matching asset URL', async () => { + requestMock.mockResolvedValue({ + data: { + tag_name: '1.4.1+20260716', + name: 'Elide 1.4.1+20260716', + assets: [ + { + name: 'elide.linux-amd64.tgz', + browser_download_url: + 'https://github.com/elide-dev/elide/releases/download/tag/elide.linux-amd64.tgz' + } + ] + } + }) + const options = buildOptions({ + os: 'linux', + arch: 'amd64', + version: '1.4.1+20260716' + }) + const result = await downloadRelease(options) + expect(requestMock).toHaveBeenCalled() + expect(downloadToolMock).toHaveBeenCalledWith( + 'https://github.com/elide-dev/elide/releases/download/tag/elide.linux-amd64.tgz' + ) + expect(result.version.tag_name).toBe('1.4.1+20260716') + }) + + it('should prefer a tgz release asset even when xz is available locally but no txz asset was published', async () => { + whichMock.mockResolvedValue('/usr/bin/xz') + requestMock.mockResolvedValue({ + data: { + tag_name: '1.4.1+20260716', + name: 'Elide 1.4.1+20260716', + assets: [ + { + name: 'elide.linux-amd64.tgz', + browser_download_url: + 'https://github.com/elide-dev/elide/releases/download/tag/elide.linux-amd64.tgz' + } + ] + } + }) + const options = buildOptions({ + os: 'linux', + arch: 'amd64', + version: '1.4.1+20260716' + }) + const result = await downloadRelease(options) + expect(downloadToolMock).toHaveBeenCalledWith( + 'https://github.com/elide-dev/elide/releases/download/tag/elide.linux-amd64.tgz' + ) + expect(result.version.tag_name).toBe('1.4.1+20260716') + }) + + it('should NOT call the GitHub API for a build-metadata pin on a warm cache hit', async () => { + findMock.mockReturnValue('/cache/tools/elide/1.4.1-build.20260716/amd64') + const options = buildOptions({ + os: 'linux', + arch: 'amd64', + version: '1.4.1+20260716' + }) + const result = await downloadRelease(options) + expect(requestMock).not.toHaveBeenCalled() + expect(downloadToolMock).not.toHaveBeenCalled() + expect(result.cached).toBe(true) + }) + + it('should NOT call the GitHub API for a "+"-containing pin that is not semver-shaped', async () => { + const options = buildOptions({ + os: 'linux', + arch: 'amd64', + version: 'myfork+patch1' + }) + await downloadRelease(options) + expect(requestMock).not.toHaveBeenCalled() + }) + + it('should fall back to the CDN URL when the build-metadata tag lookup fails', async () => { + requestMock.mockRejectedValue( + Object.assign(new Error('Not Found'), { status: 404 }) + ) + const options = buildOptions({ + os: 'linux', + arch: 'amd64', + version: '1.4.1+20260716' + }) + const result = await downloadRelease(options) + expect(downloadToolMock).toHaveBeenCalledWith( + expect.stringContaining('elide.zip/artifacts') + ) + expect(result.version.tag_name).toBe('1.4.1+20260716') + }) + + it('should NOT call the GitHub API for a plain semver pin (scoping regression guard)', async () => { + const options = buildOptions({ + os: 'linux', + arch: 'amd64', + version: '1.2.3' + }) + await downloadRelease(options) + expect(requestMock).not.toHaveBeenCalled() + }) + it('should use a cached copy when available', async () => { findMock.mockReturnValue('/cached/elide') const options = buildOptions({ diff --git a/__tests__/releases.test.ts b/__tests__/releases.test.ts index 320b937..271344a 100644 --- a/__tests__/releases.test.ts +++ b/__tests__/releases.test.ts @@ -14,12 +14,62 @@ const { resolveLatestVersion, buildDownloadUrl, buildCdnAssetUrl, + toSemverCacheKey, ArchiveType, cdnOs, cdnArch } = await import('../src/releases') const { default: buildOptions } = await import('../src/options') +describe('toSemverCacheKey', () => { + it('should leave plain semver unchanged', () => { + expect(toSemverCacheKey('1.0.0')).toBe('1.0.0') + }) + + it('should leave semver prerelease unchanged', () => { + expect(toSemverCacheKey('1.0.0-beta10')).toBe('1.0.0-beta10') + }) + + it('should convert nightly tag to prerelease form', () => { + expect(toSemverCacheKey('nightly-20260328')).toBe('0.0.0-nightly.20260328') + }) + + it('should strip dashes from nightly date portion', () => { + expect(toSemverCacheKey('nightly-2026-03-28')).toBe( + '0.0.0-nightly.20260328' + ) + }) + + it('should convert build metadata tag to prerelease form', () => { + expect(toSemverCacheKey('1.4.0+20260707')).toBe('1.4.0-build.20260707') + }) + + it('should preserve prerelease when build metadata is present', () => { + expect(toSemverCacheKey('1.0.0-beta10+20260707')).toBe( + '1.0.0-beta10-build.20260707' + ) + }) + + it('should handle multi-part build metadata', () => { + // e.g. if the tag itself embeds date+commit: 1.4.0+20260707.abc123 + expect(toSemverCacheKey('1.4.0+20260707.abc123')).toBe( + '1.4.0-build.20260707.abc123' + ) + }) + + it('should pass through build metadata with invalid semver identifier characters unchanged', () => { + // An underscore isn't a valid semver identifier character; converting + // this to a "-build.foo_bar" prerelease would itself not be valid + // semver, defeating the point of toSemverCacheKey. Leave it unchanged + // rather than emit an unusable cache key. + expect(toSemverCacheKey('1.0.0+foo_bar')).toBe('1.0.0+foo_bar') + }) + + it('should not treat a non-semver "+"-containing string as build metadata', () => { + expect(toSemverCacheKey('myfork+patch1')).toBe('myfork+patch1') + }) +}) + describe('elide release', () => { it('should support resolving the latest version', async () => { expect(await resolveLatestVersion()).not.toBeNull() diff --git a/src/install-apt.ts b/src/install-apt.ts index 4104d97..b632603 100644 --- a/src/install-apt.ts +++ b/src/install-apt.ts @@ -75,7 +75,7 @@ export async function installViaApt( return { version: { - tag_name: version, + tag_name: options.version, userProvided: options.version !== 'latest' }, elidePath, diff --git a/src/install-msi.ts b/src/install-msi.ts index c52d33f..5238be1 100644 --- a/src/install-msi.ts +++ b/src/install-msi.ts @@ -33,7 +33,10 @@ export async function installViaMsi( core.info(`Elide ${version} installed via MSI at ${elidePath}`) return { - version: { tag_name: version, userProvided: options.version !== 'latest' }, + version: { + tag_name: options.version, + userProvided: options.version !== 'latest' + }, elidePath, elideHome, elideBin diff --git a/src/install-pkg.ts b/src/install-pkg.ts index d923e24..ee8dc96 100644 --- a/src/install-pkg.ts +++ b/src/install-pkg.ts @@ -26,7 +26,10 @@ export async function installViaPkg( core.info(`Elide ${version} installed via PKG at ${elidePath}`) return { - version: { tag_name: version, userProvided: options.version !== 'latest' }, + version: { + tag_name: options.version, + userProvided: options.version !== 'latest' + }, elidePath, elideHome, elideBin diff --git a/src/install-rpm.ts b/src/install-rpm.ts index f61d072..075caae 100644 --- a/src/install-rpm.ts +++ b/src/install-rpm.ts @@ -40,7 +40,10 @@ export async function installViaRpm( core.info(`Elide ${version} installed via RPM at ${elidePath}`) return { - version: { tag_name: version, userProvided: options.version !== 'latest' }, + version: { + tag_name: options.version, + userProvided: options.version !== 'latest' + }, elidePath, elideHome, elideBin diff --git a/src/install-shell.ts b/src/install-shell.ts index 7cff683..048f5bb 100644 --- a/src/install-shell.ts +++ b/src/install-shell.ts @@ -69,7 +69,7 @@ async function installViaBash( return { version: { - tag_name: version, + tag_name: options.version, userProvided: options.version !== 'latest' }, elidePath, @@ -105,7 +105,7 @@ async function installViaPowerShell( return { version: { - tag_name: version, + tag_name: options.version, userProvided: options.version !== 'latest' }, elidePath, diff --git a/src/main.ts b/src/main.ts index 67c4ec5..42699a1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,16 @@ import { installViaMsi } from './install-msi' import { installViaPkg } from './install-pkg' import { installViaRpm } from './install-rpm' +/** + * Returns true if a binary's self-reported version corresponds to the requested + * tag. An exact match always qualifies; a commit-hash suffix separated by a dot + * (e.g. "1.4.0+20260707.6f7ffa7" for tag "1.4.0+20260707") also qualifies, + * because the binary appends ".{commithash}" to the tag it was built from. + */ +export function versionMatchesTag(ver: string, tag: string): boolean { + return ver === tag || ver.startsWith(tag + '.') +} + export function notSupported(options: ElideSetupActionOptions): null | Error { const spec = `${options.os}-${options.arch}` switch (spec) { @@ -183,7 +193,7 @@ export async function run( const version = await obtainVersion(existing) if ( - version === effectiveOptions.version || + versionMatchesTag(version, effectiveOptions.version) || effectiveOptions.version === 'local' ) { core.notice(`Existing Elide ${version} preserved at ${existing}`, { @@ -255,7 +265,12 @@ export async function run( const ver = await obtainVersion(release.elidePath) const isNightly = release.version.tag_name.startsWith('nightly-') - if (!isNightly && ver !== release.version.tag_name) { + const isSymbolic = release.version.tag_name === 'latest' + if ( + !isNightly && + !isSymbolic && + !versionMatchesTag(ver, release.version.tag_name) + ) { core.warning( `Elide version mismatch: expected '${release.version.tag_name}', but got '${ver}'`, { title: 'Version Mismatch' } diff --git a/src/releases.ts b/src/releases.ts index 6bb753c..dd06196 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -19,6 +19,16 @@ const GITHUB_DEFAULT_HEADERS = { // Matches tags like "nightly-20260328" or "nightly-2026-03-28" const NIGHTLY_TAG_RE = /^nightly-(.+)$/ +// Matches tags with semver build metadata, like "1.4.0+20260707". Requires a +// semver-shaped prefix (major.minor.patch, optional prerelease) and a build +// segment made of valid semver identifiers (dot-separated alphanumerics/ +// hyphens), so arbitrary "+"-containing strings (fork tags, typos, or a +// build segment with e.g. an underscore) don't get treated as nightly +// build-metadata tags — toSemverCacheKey's `-build.` substitution +// must itself always be a semver-cleanable string. +const BUILD_META_TAG_RE = + /^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)$/ + /** * Convert a release tag to a valid semver string for use with @actions/tool-cache. * @@ -26,13 +36,14 @@ const NIGHTLY_TAG_RE = /^nightly-(.+)$/ * for non-semver strings like "nightly-20260328". This causes cache lookups to * silently fail (never hit, never store correctly). * - * Mapping: - * "1.0.0" → "1.0.0" (already semver) - * "1.0.0-beta10" → "1.0.0-beta10" (valid semver prerelease) - * "nightly-20260328" → "0.0.0-nightly.20260328" - * * We use prerelease (not build metadata with +) because semver.clean strips * build metadata, making it useless for cache key matching. + * + * Mapping: + * "1.0.0" → "1.0.0" (already semver) + * "1.0.0-beta10" → "1.0.0-beta10" (valid semver prerelease) + * "1.4.0+20260707" → "1.4.0-build.20260707" (build metadata → prerelease) + * "nightly-20260328" → "0.0.0-nightly.20260328" */ export function toSemverCacheKey(tag: string): string { const nightlyMatch = tag.match(NIGHTLY_TAG_RE) @@ -41,9 +52,25 @@ export function toSemverCacheKey(tag: string): string { const datePart = nightlyMatch[1].replaceAll('-', '') return `0.0.0-nightly.${datePart}` } + const buildMetaMatch = tag.match(BUILD_META_TAG_RE) + if (buildMetaMatch) { + // semver.clean strips +build metadata; convert to prerelease so it's preserved + return `${buildMetaMatch[1]}-build.${buildMetaMatch[2]}` + } return tag } +/** + * A single downloadable asset attached to a GitHub release. + */ +export type ReleaseAsset = { + // Asset file name, e.g. "elide.linux-amd64.tgz". + name: string + + // Direct download URL for the asset (GitHub's `browser_download_url`). + url: string +} + /** * Version info resolved for a release of Elide. */ @@ -56,6 +83,9 @@ export type ElideVersionInfo = { // Whether this version is resolved (`false`) or user-provided (`true`). userProvided: boolean + + // Release assets, if resolved via the GitHub API (nightly/build-metadata tags only). + assets?: ReleaseAsset[] } /** @@ -168,28 +198,71 @@ export function buildCdnAssetUrl( ) } +/** + * Find a release asset matching the current platform, if one is available. + * + * @param version Resolved version info (may or may not carry `assets`). + * @param options Effective options (uses os, arch). + * @param ext File extension to match (e.g. 'tgz', 'txz', 'zip'). + * @return The asset's download URL, or `null` if none matched. + */ +function findAssetUrl( + version: ElideVersionInfo, + options: ElideSetupActionOptions, + ext: string +): string | null { + if (!version.assets || version.assets.length === 0) { + return null + } + const os = cdnOs(options.os) + const arch = cdnArch(options.arch) + const assetName = `elide.${os}-${arch}.${ext}` + return version.assets.find(a => a.name === assetName)?.url ?? null +} + /** * Build a download URL for an Elide release archive. * Selects the best archive format based on local tool availability. + * If `version` carries release assets with a match for the current platform + * (resolved via {@link resolveVersionByTag} for nightly/build-metadata tags), + * that asset's URL is preferred over the CDN URL. * * @param options Effective options. + * @param version Resolved version info; may carry release assets to prefer. * @return URL and archive type to use. */ export async function buildDownloadUrl( - options: ElideSetupActionOptions + options: ElideSetupActionOptions, + version?: ElideVersionInfo ): Promise<{ url: URL; archiveType: ArchiveType }> { - let ext = 'tgz' - let archiveType = ArchiveType.GZIP const hasXz = await which('xz') - if (options.os === ElideOS.WINDOWS) { - ext = 'zip' - archiveType = ArchiveType.ZIP - } else if (hasXz) { - ext = 'txz' - archiveType = ArchiveType.TXZ + // Candidate archive formats in preference order. TXZ requires the `xz` + // CLI tool locally (unpackRelease shells out to it); GZIP/ZIP don't. A + // release may not publish every format, so when matching against release + // assets we try each viable candidate rather than committing to just the + // most-preferred one and missing an asset published in another format. + const candidates: { ext: string; archiveType: ArchiveType }[] = + options.os === ElideOS.WINDOWS + ? [{ ext: 'zip', archiveType: ArchiveType.ZIP }] + : hasXz + ? [ + { ext: 'txz', archiveType: ArchiveType.TXZ }, + { ext: 'tgz', archiveType: ArchiveType.GZIP } + ] + : [{ ext: 'tgz', archiveType: ArchiveType.GZIP }] + + if (version) { + for (const candidate of candidates) { + const assetUrl = findAssetUrl(version, options, candidate.ext) + if (assetUrl) { + core.debug(`Using GitHub release asset for download: ${assetUrl}`) + return { archiveType: candidate.archiveType, url: new URL(assetUrl) } + } + } } + const { ext, archiveType } = candidates[0] return { archiveType, url: buildCdnAssetUrl(options, ext) @@ -361,18 +434,97 @@ export async function resolveLatestVersion( throw lastError! } +/** + * Resolve a nightly or build-metadata-tagged release by its exact tag, via the GitHub + * Releases API. Used only for tags shaped like `nightly-<...>` or `+`, + * since the CDN does not reliably publish artifacts at those exact revision strings. + * + * Unlike `resolveLatestVersion`, this function never throws: on any failure (not-found, + * transient error, exhausted retries) it falls back to a plain, asset-less version info + * object so callers degrade gracefully to today's CDN-only download path. + * + * @param tag The exact tag to look up (e.g. "1.4.1+20260716" or "nightly-20260328"). + * @param token GitHub token active for this workflow step. + */ +export async function resolveVersionByTag( + tag: string, + token?: string +): Promise { + if (!token) { + core.warning( + 'No GitHub token provided. API requests may be rate-limited. ' + + 'Set the `token` input or ensure GITHUB_TOKEN is available.' + ) + } + const octokit = token ? github.getOctokit(token) : new Octokit({}) + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + const release = await octokit.request( + 'GET /repos/{owner}/{repo}/releases/tags/{tag}', + { + owner: 'elide-dev', + repo: 'elide', + tag, + headers: GITHUB_DEFAULT_HEADERS + } + ) + + const name = release.data?.name || undefined + const assets: ReleaseAsset[] = (release.data.assets ?? []).map(a => ({ + name: a.name, + url: a.browser_download_url + })) + return { + tag_name: tag, + name, + userProvided: true, + assets + } + } catch (err) { + const lastError = err instanceof Error ? err : new Error(String(err)) + const status = (err as { status?: number } | undefined)?.status + const isNotFound = + status === 404 || lastError.message.includes('Not Found') + + if (isNotFound) { + core.debug( + `No release found for tag '${tag}' via GitHub API; falling back to CDN. (${lastError.message})` + ) + break + } + + if (attempt < MAX_RETRIES) { + const delay = RETRY_DELAY_MS * attempt + core.warning( + `GitHub API request failed (attempt ${attempt}/${MAX_RETRIES}): ${lastError.message}. Retrying in ${delay}ms...` + ) + await new Promise(resolve => setTimeout(resolve, delay)) + } else { + core.warning( + `Exhausted retries resolving tag '${tag}' via GitHub API; falling back to CDN. (${lastError.message})`, + { title: 'GitHub API Resolution Failed' } + ) + } + } + } + + return { tag_name: tag, userProvided: true } +} + /** * Conditionally download the desired version of Elide, or use a cached version, if available. * * @param version Resolved version info for the desired copy of Elide. * @param options Effective setup action options. + * @param resolveAssets Optional lazy resolver for release assets (e.g. {@link resolveVersionByTag}). + * Invoked only on a cache miss, since a cache hit never needs release assets to build a download URL. */ async function maybeDownload( version: ElideVersionInfo, - options: ElideSetupActionOptions + options: ElideSetupActionOptions, + resolveAssets?: () => Promise ): Promise { - // build download URL, use result from cache or disk - const { url, archiveType } = await buildDownloadUrl(options) const sep = options.os === ElideOS.WINDOWS ? '\\' : '/' const binName = options.os === ElideOS.WINDOWS ? 'elide.exe' : 'elide' let targetBin = `${options.install_path}${sep}bin${sep}${binName}` @@ -413,6 +565,13 @@ async function maybeDownload( core.debug('Cache enabled but no hit was found; downloading release') } + // Only resolve release assets (a GitHub API round-trip) once we know + // we actually need to download — a cache hit never needs them. + if (resolveAssets) { + version = await resolveAssets() + } + const { url, archiveType } = await buildDownloadUrl(options, version) + core.info(`Installing from URL: ${url} (type: ${archiveType})`) // we do not have an existing copy; download it @@ -519,9 +678,24 @@ export async function downloadRelease( } else { // resolve applicable version let versionInfo: ElideVersionInfo + let resolveAssets: (() => Promise) | undefined if (options.version === 'latest') { core.debug('Resolving latest version via GitHub API') versionInfo = await resolveLatestVersion(options.token) + } else if ( + NIGHTLY_TAG_RE.test(options.version) || + BUILD_META_TAG_RE.test(options.version) + ) { + // Defer the GitHub API call until maybeDownload confirms a cache miss — + // a cache hit never needs release assets, only the tag string below. + versionInfo = { tag_name: options.version, userProvided: true } + const pinnedVersion = options.version + resolveAssets = () => { + core.debug( + `Resolving pinned nightly/build-metadata tag '${pinnedVersion}' via GitHub API` + ) + return resolveVersionByTag(pinnedVersion, options.token) + } } else { versionInfo = { tag_name: options.version, @@ -530,6 +704,6 @@ export async function downloadRelease( } // setup caching with the effective version and perform download - return maybeDownload(versionInfo, options) + return maybeDownload(versionInfo, options, resolveAssets) } }