From a4adb936da350c2748e87d1953137c990da6a833 Mon Sep 17 00:00:00 2001 From: melodicore Date: Sat, 11 Jul 2026 14:52:07 +0300 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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 From a5d3bb64510445984833f71ec581d6341e313962 Mon Sep 17 00:00:00 2001 From: melodicore Date: Mon, 20 Jul 2026 20:17:19 +0300 Subject: [PATCH 6/7] fix: resolve pinned nightly/build-metadata versions via GitHub Releases API Explicit version pins were never resolved via the GitHub API (only `latest` was), so nightly/build-metadata tags (e.g. `1.4.1+20260716`) were passed straight through to the CDN, which doesn't reliably publish artifacts at that exact revision string and 404s. Add `resolveVersionByTag`, scoped to tags matching the existing nightly/build-metadata regexes, which looks up the release by tag and prefers a matching GitHub release asset over the CDN URL, falling back gracefully (never throwing) on any lookup failure. The GitHub API call is deferred until a tool-cache miss is confirmed, and `buildDownloadUrl` now tries multiple archive extensions against the release's assets instead of committing to one and missing a real asset published in a different format. Tightened `BUILD_META_TAG_RE` to require a semver-shaped prefix so arbitrary `+`-containing pins don't trigger this path. --- README.md | 2 +- __tests__/releases-download.test.ts | 176 ++++++++++++++++++++++++- src/releases.ts | 191 +++++++++++++++++++++++++--- 3 files changed, 351 insertions(+), 18 deletions(-) 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__/releases-download.test.ts b/__tests__/releases-download.test.ts index 468abc4..746de4d 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,71 @@ describe('resolveLatestVersion', () => { }) }) +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 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 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 +259,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/src/releases.ts b/src/releases.ts index 70d44ab..956ba1f 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -19,8 +19,11 @@ 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 = /^([^+]+)\+(.+)$/ +// Matches tags with semver build metadata, like "1.4.0+20260707". Requires a +// semver-shaped prefix (major.minor.patch, optional prerelease) so arbitrary +// "+"-containing strings (fork tags, typos) don't get treated as nightly +// build-metadata tags. +const BUILD_META_TAG_RE = /^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\+(.+)$/ /** * Convert a release tag to a valid semver string for use with @actions/tool-cache. @@ -53,6 +56,17 @@ export function toSemverCacheKey(tag: string): string { 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. */ @@ -65,6 +79,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[] } /** @@ -177,28 +194,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) @@ -370,18 +430,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}` @@ -422,6 +561,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 @@ -528,9 +674,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, @@ -539,6 +700,6 @@ export async function downloadRelease( } // setup caching with the effective version and perform download - return maybeDownload(versionInfo, options) + return maybeDownload(versionInfo, options, resolveAssets) } } From b37062a741439edb471977bf94e57ac5e81987b9 Mon Sep 17 00:00:00 2001 From: melodicore Date: Mon, 20 Jul 2026 20:35:40 +0300 Subject: [PATCH 7/7] fix: address Copilot review feedback on build-metadata regex and test timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BUILD_META_TAG_RE now also restricts the build segment to valid semver identifiers (dot-separated alphanumerics/hyphens), not any characters. Previously a tag like "1.0.0+foo_bar" would match and toSemverCacheKey would emit "1.0.0-build.foo_bar" — itself not valid semver (underscore), defeating the point of the conversion. - resolveVersionByTag's retry tests were sleeping for real between attempts (1s-3s). Added a small setTimeout override so the retry path is exercised without the wall-clock delay. --- __tests__/releases-download.test.ts | 24 ++++++++++++++++++++++-- __tests__/releases.test.ts | 12 ++++++++++++ src/releases.ts | 12 ++++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/__tests__/releases-download.test.ts b/__tests__/releases-download.test.ts index 746de4d..3232960 100644 --- a/__tests__/releases-download.test.ts +++ b/__tests__/releases-download.test.ts @@ -119,6 +119,22 @@ 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() @@ -162,7 +178,9 @@ describe('resolveVersionByTag', () => { .mockResolvedValueOnce({ data: { tag_name: 'nightly-20260328', assets: [] } }) - const result = await resolveVersionByTag('nightly-20260328') + const result = await withInstantRetryDelay(() => + resolveVersionByTag('nightly-20260328') + ) expect(result.tag_name).toBe('nightly-20260328') expect(requestMock).toHaveBeenCalledTimes(2) }) @@ -178,7 +196,9 @@ describe('resolveVersionByTag', () => { it('should fall back after exhausting retries on transient errors, without throwing', async () => { requestMock.mockRejectedValue(new Error('quota exhausted')) - const result = await resolveVersionByTag('1.4.1+20260716') + const result = await withInstantRetryDelay(() => + resolveVersionByTag('1.4.1+20260716') + ) expect(result).toEqual({ tag_name: '1.4.1+20260716', userProvided: true }) expect(requestMock).toHaveBeenCalledTimes(3) }) diff --git a/__tests__/releases.test.ts b/__tests__/releases.test.ts index 1b2140a..271344a 100644 --- a/__tests__/releases.test.ts +++ b/__tests__/releases.test.ts @@ -56,6 +56,18 @@ describe('toSemverCacheKey', () => { '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', () => { diff --git a/src/releases.ts b/src/releases.ts index 956ba1f..dd06196 100644 --- a/src/releases.ts +++ b/src/releases.ts @@ -20,10 +20,14 @@ const GITHUB_DEFAULT_HEADERS = { 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) so arbitrary -// "+"-containing strings (fork tags, typos) don't get treated as nightly -// build-metadata tags. -const BUILD_META_TAG_RE = /^(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\+(.+)$/ +// 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.