From a4adb936da350c2748e87d1953137c990da6a833 Mon Sep 17 00:00:00 2001 From: melodicore Date: Sat, 11 Jul 2026 14:52:07 +0300 Subject: [PATCH 1/5] fix: correct version matching and tool cache key for build-metadata tags Tags like 1.4.0+20260707 were collapsed to 1.4.0 by semver.clean inside tool-cache, causing cache collisions across different dated builds of the same version. toSemverCacheKey now converts build metadata to a prerelease segment (1.4.0+20260707 -> 1.4.0-build.20260707) which semver.clean preserves. The existing-binary skip check and post-install mismatch warning were also doing exact string equality against the tag, which always failed because elide --version appends a commit hash suffix (.6f7ffa7) not present in the tag. Both comparisons now accept the binary version if it starts with the requested tag followed by a dot. --- __tests__/releases.test.ts | 31 +++++++++++++++++++++++++++++++ src/main.ts | 6 +++++- src/releases.ts | 19 ++++++++++++++----- 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/__tests__/releases.test.ts b/__tests__/releases.test.ts index 320b937..dd0113e 100644 --- a/__tests__/releases.test.ts +++ b/__tests__/releases.test.ts @@ -14,12 +14,43 @@ 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' + ) + }) +}) + describe('elide release', () => { it('should support resolving the latest version', async () => { expect(await resolveLatestVersion()).not.toBeNull() diff --git a/src/main.ts b/src/main.ts index 67c4ec5..cd3c05c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -184,6 +184,7 @@ export async function run( if ( version === effectiveOptions.version || + version.startsWith(effectiveOptions.version + '.') || effectiveOptions.version === 'local' ) { core.notice(`Existing Elide ${version} preserved at ${existing}`, { @@ -255,7 +256,10 @@ 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 versionMatches = + ver === release.version.tag_name || + ver.startsWith(release.version.tag_name + '.') + if (!isNightly && !versionMatches) { 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 } From ff09e3a1141cdef4c6f86a73fccc02e54ed90bf4 Mon Sep 17 00:00:00 2001 From: melodicore Date: Sat, 11 Jul 2026 14:56:32 +0300 Subject: [PATCH 2/5] test: extract versionMatchesTag helper and add edge-case coverage Pulling the binary-vs-tag comparison into a named exported function makes it independently testable. New tests cover exact matches, commit-hash suffixes, build-metadata tags, and false-positive guards (no dot separator, mismatched build dates, binary ahead of prerelease tag). Also adds a multi-part build metadata case to the toSemverCacheKey suite (e.g. 1.4.0+20260707.abc123) to guard against future tag format changes. --- __tests__/main.test.ts | 50 ++++++++++++++++++++++++++++++++++++++ __tests__/releases.test.ts | 7 ++++++ src/main.ts | 18 +++++++++----- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index c094f9f..aaefcd0 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,55 @@ 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() diff --git a/__tests__/releases.test.ts b/__tests__/releases.test.ts index dd0113e..1b2140a 100644 --- a/__tests__/releases.test.ts +++ b/__tests__/releases.test.ts @@ -49,6 +49,13 @@ describe('toSemverCacheKey', () => { '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', () => { diff --git a/src/main.ts b/src/main.ts index cd3c05c..237bff3 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,8 +193,7 @@ export async function run( const version = await obtainVersion(existing) if ( - version === effectiveOptions.version || - version.startsWith(effectiveOptions.version + '.') || + versionMatchesTag(version, effectiveOptions.version) || effectiveOptions.version === 'local' ) { core.notice(`Existing Elide ${version} preserved at ${existing}`, { @@ -256,10 +265,7 @@ export async function run( const ver = await obtainVersion(release.elidePath) const isNightly = release.version.tag_name.startsWith('nightly-') - const versionMatches = - ver === release.version.tag_name || - ver.startsWith(release.version.tag_name + '.') - if (!isNightly && !versionMatches) { + if (!isNightly && !versionMatchesTag(ver, release.version.tag_name)) { core.warning( `Elide version mismatch: expected '${release.version.tag_name}', but got '${ver}'`, { title: 'Version Mismatch' } From cd81d77d364b8cfbc69d558c10a9a2ac311ed2b5 Mon Sep 17 00:00:00 2001 From: melodicore Date: Sat, 11 Jul 2026 15:38:59 +0300 Subject: [PATCH 3/5] fix: version mismatch warning never fired for non-archive installers Shell, apt, msi, pkg, and rpm installers all set ElideRelease.version.tag_name to the binary's own obtainVersion() output. main.ts then compared that same value against itself in the post-install mismatch check, so the warning could never fire regardless of what version the user requested. Fix: non-archive installers now set tag_name to options.version (the version the user requested). The mismatch check gains an isSymbolic guard to skip verification when tag_name is 'latest', since that cannot be compared against a real binary version string. --- __tests__/install-apt.test.ts | 2 +- __tests__/install-msi.test.ts | 2 +- __tests__/install-pkg.test.ts | 2 +- __tests__/install-rpm.test.ts | 6 +++--- __tests__/install-shell.test.ts | 4 ++-- __tests__/main.test.ts | 2 +- src/install-apt.ts | 2 +- src/install-msi.ts | 2 +- src/install-pkg.ts | 2 +- src/install-rpm.ts | 2 +- src/install-shell.ts | 4 ++-- src/main.ts | 3 ++- 12 files changed, 17 insertions(+), 16 deletions(-) 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 aaefcd0..4afb967 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -486,7 +486,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/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..9e9abf2 100644 --- a/src/install-msi.ts +++ b/src/install-msi.ts @@ -33,7 +33,7 @@ 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..32997e2 100644 --- a/src/install-pkg.ts +++ b/src/install-pkg.ts @@ -26,7 +26,7 @@ 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..2dfff28 100644 --- a/src/install-rpm.ts +++ b/src/install-rpm.ts @@ -40,7 +40,7 @@ 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 237bff3..05a389a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -265,7 +265,8 @@ export async function run( const ver = await obtainVersion(release.elidePath) const isNightly = release.version.tag_name.startsWith('nightly-') - if (!isNightly && !versionMatchesTag(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' } From 542f93e2986a45d3a6ac428b51ccafa2e143fa73 Mon Sep 17 00:00:00 2001 From: melodicore Date: Sat, 11 Jul 2026 15:48:18 +0300 Subject: [PATCH 4/5] chore: fmt --- __tests__/main.test.ts | 10 ++++------ src/install-msi.ts | 5 ++++- src/install-pkg.ts | 5 ++++- src/install-rpm.ts | 5 ++++- src/main.ts | 6 +++++- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 4afb967..cd31818 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -124,9 +124,7 @@ describe('versionMatchesTag', () => { }) it('commit-hash suffix on prerelease tag', () => { - expect(versionMatchesTag('1.0.0-beta10.6f7ffa7', '1.0.0-beta10')).toBe( - true - ) + expect(versionMatchesTag('1.0.0-beta10.6f7ffa7', '1.0.0-beta10')).toBe(true) }) it('commit-hash suffix on build-metadata tag', () => { @@ -149,9 +147,9 @@ describe('versionMatchesTag', () => { }) 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) + expect(versionMatchesTag('1.4.0+20260708.6f7ffa7', '1.4.0+20260707')).toBe( + false + ) }) }) diff --git a/src/install-msi.ts b/src/install-msi.ts index 9e9abf2..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: options.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 32997e2..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: options.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 2dfff28..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: options.version, userProvided: options.version !== 'latest' }, + version: { + tag_name: options.version, + userProvided: options.version !== 'latest' + }, elidePath, elideHome, elideBin diff --git a/src/main.ts b/src/main.ts index 05a389a..42699a1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -266,7 +266,11 @@ export async function run( const isNightly = release.version.tag_name.startsWith('nightly-') const isSymbolic = release.version.tag_name === 'latest' - if (!isNightly && !isSymbolic && !versionMatchesTag(ver, release.version.tag_name)) { + 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' } From 9724abd88c4b466ae63fc1fb0d519ef1dede2c26 Mon Sep 17 00:00:00 2001 From: melodicore Date: Sat, 11 Jul 2026 16:04:53 +0300 Subject: [PATCH 5/5] ci: remove macos-amd64 test matrix entry Elide no longer ships a macOS AMD64 binary; only macos-arm64 is distributed. --- .github/workflows/ci.yml | 5 ----- 1 file changed, 5 deletions(-) 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