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/__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.test.ts b/__tests__/releases.test.ts index 320b937..1b2140a 100644 --- a/__tests__/releases.test.ts +++ b/__tests__/releases.test.ts @@ -14,12 +14,50 @@ 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' + ) + }) +}) + 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..70d44ab 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -19,6 +19,9 @@ 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" +const BUILD_META_TAG_RE = /^([^+]+)\+(.+)$/ + /** * Convert a release tag to a valid semver string for use with @actions/tool-cache. * @@ -26,13 +29,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,6 +45,11 @@ 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 }