diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fafe8e8..2b896c3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -66,7 +66,7 @@ jobs: git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" push origin "HEAD:${GITHUB_REF_NAME}" integration: - name: Integration (${{ matrix.os }}, wrapper=${{ matrix.install-wrapper }}) + name: Integration (${{ matrix.os }}, ${{ matrix.version }}, wrapper=${{ matrix.install-wrapper }}) runs-on: ${{ matrix.os }} permissions: contents: read @@ -80,6 +80,10 @@ jobs: install-wrapper: - "false" - "true" + # `latest` exercises the getLatestRelease path; a pinned version exercises getReleaseByTag. + version: + - latest + - "1.224.1" steps: - uses: actions/checkout@v7 with: @@ -88,11 +92,25 @@ jobs: persist-credentials: false - name: Setup Atmos + id: setup uses: ./ with: - atmos-version: latest + atmos-version: ${{ matrix.version }} install-wrapper: ${{ matrix.install-wrapper }} + - name: Verify resolved version + if: matrix.version != 'latest' + env: + RESOLVED: ${{ steps.setup.outputs.atmos-version }} + EXPECTED: ${{ matrix.version }} + shell: bash + run: | + printf 'resolved: %s (expected v%s)\n' "$RESOLVED" "$EXPECTED" + if [ "$RESOLVED" != "v${EXPECTED}" ]; then + echo "::error::setup-atmos resolved '$RESOLVED' but 'v${EXPECTED}' was requested." + exit 1 + fi + - name: Run Atmos id: atmos run: atmos version diff --git a/dist/index.js b/dist/index.js index 55d5675..e87c3da 100644 --- a/dist/index.js +++ b/dist/index.js @@ -48989,32 +48989,42 @@ const findVersionMatch = (versionSpec, arch = external_os_default().arch(), cand } return result; }; -const getVersionsFromGitHubReleases = async (auth) => { - const octokit = new dist_node.Octokit({ auth }); - const versions = []; - for await (const release of octokit.paginate.iterator(octokit.rest.repos.listReleases, { - owner: "cloudposse", - repo: "atmos" - })) { - release.data.forEach((r) => { - const { tag_name, prerelease } = r; - if (!tag_name) { - throw new Error(`Release tag is empty`); - } - const assets = r.assets.flatMap((asset) => { - const { name, browser_download_url } = asset; - const atmosAsset = parseAtmosReleaseAsset(name, browser_download_url); - return atmosAsset ? [atmosAsset] : []; - }); - const checksumsUrl = r.assets.find((asset) => asset.name === getChecksumsAssetName(tag_name))?.browser_download_url; - const version = { name: tag_name, prerelease, assets, checksumsUrl }; - versions.push(version); - }); +const createOctokit = (auth) => { + return new dist_node.Octokit({ auth }); +}; +const mapReleaseToAtmosVersion = (release) => { + const { prerelease, tag_name } = release; + if (!tag_name) { + throw new Error(`Release tag is empty`); + } + const assets = release.assets.flatMap((asset) => { + const { browser_download_url, name } = asset; + const atmosAsset = parseAtmosReleaseAsset(name, browser_download_url); + return atmosAsset ? [atmosAsset] : []; + }); + const checksumsUrl = release.assets.find((asset) => asset.name === getChecksumsAssetName(tag_name))?.browser_download_url; + return { name: tag_name, prerelease, assets, checksumsUrl }; +}; +const getReleaseByTag = async (versionSpec, auth) => { + const octokit = createOctokit(auth); + const tag = `v${semver.clean(versionSpec)}`; + try { + const { data } = await octokit.rest.repos.getReleaseByTag({ owner: "cloudposse", repo: "atmos", tag }); + return [mapReleaseToAtmosVersion(data)]; + } + catch (e) { + if (e instanceof dist_node.RequestError && e.status === 404) { + return null; + } + throw e; } - return versions; }; -const getMatchingVersion = async (versionSpec, auth, arch) => { - const candidates = await getVersionsFromGitHubReleases(auth); +const getLatestRelease = async (auth) => { + const octokit = createOctokit(auth); + const { data } = await octokit.rest.repos.getLatestRelease({ owner: "cloudposse", repo: "atmos" }); + return [mapReleaseToAtmosVersion(data)]; +}; +const buildVersionInfo = (versionSpec, arch, candidates) => { const version = findVersionMatch(versionSpec, arch, candidates); if (!version) { return null; @@ -49026,6 +49036,41 @@ const getMatchingVersion = async (versionSpec, auth, arch) => { checksumsUrl: version.checksumsUrl }; }; +const resolveFromReleaseList = async (versionSpec, auth, arch) => { + const octokit = createOctokit(auth); + const seen = []; + for await (const page of octokit.paginate.iterator(octokit.rest.repos.listReleases, { + owner: "cloudposse", + repo: "atmos", + per_page: 100 + })) { + page.data.forEach((r) => seen.push(mapReleaseToAtmosVersion(r))); + const info = buildVersionInfo(versionSpec, arch, seen); + if (info) { + return info; + } + } + return null; +}; +const getMatchingVersion = async (versionSpec, auth, arch) => { + // Exact version: fetch just that release by tag. + if (semver.valid(versionSpec)) { + const candidates = await getReleaseByTag(versionSpec, auth); + const info = candidates && buildVersionInfo(versionSpec, arch, candidates); + if (info) { + return info; + } + } + else if (versionSpec === "latest") { + // Latest: fetch just the newest release. + const info = buildVersionInfo(versionSpec, arch, await getLatestRelease(auth)); + if (info) { + return info; + } + } + // Ranges, or a fallback when the targeted lookup above found no match: page through the releases. + return resolveFromReleaseList(versionSpec, auth, arch); +}; const installWrapperBin = async (atmosDownloadPath) => { let source = ""; let destination = ""; @@ -49055,7 +49100,6 @@ const installWrapperBin = async (atmosDownloadPath) => { } core.exportVariable("ATMOS_CLI_PATH", atmosDownloadPath); return atmosDownloadPath; - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e) { core.setFailed(`Unable to copy ${source} to ${destination}.`); @@ -49091,23 +49135,34 @@ const installAtmosVersion = async (info, auth, arch, installWrapper, checksumVal }; const getAtmos = async (versionSpec, auth, arch = external_os_default().arch(), installWrapper, checksumValidation = "warn") => { const osPlat = external_os_default().platform(); + const toolCacheName = getToolCacheName(installWrapper); core.info(`Attempting to download ${versionSpec}...`); + const useCachedTool = (cachedPath, resolved) => { + core.info(`Found in cache @ ${cachedPath}`); + configureInstalledPath(cachedPath, installWrapper); + return { toolPath: cachedPath, info: resolved }; + }; + if (semver.valid(versionSpec)) { + const cachedPath = tool_cache.find(toolCacheName, versionSpec, arch); + if (cachedPath) { + return useCachedTool(cachedPath, { + downloadUrl: "", + resolvedVersion: `v${semver.clean(versionSpec)}`, + fileName: "" + }); + } + } const info = await getMatchingVersion(versionSpec, auth, arch); if (!info) { throw new Error(`Unable to find atmos version '${versionSpec}' for platform ${osPlat} and architecture ${arch}.`); } const { resolvedVersion } = info; - const toolCacheName = getToolCacheName(installWrapper); - // Check to see if the version is already in the local cache - let toolPath; - toolPath = tool_cache.find(toolCacheName, resolvedVersion, arch); - if (toolPath) { - core.info(`Found in cache @ ${toolPath}`); - configureInstalledPath(toolPath, installWrapper); - return { toolPath, info }; + const cachedPath = tool_cache.find(toolCacheName, resolvedVersion, arch); + if (cachedPath) { + return useCachedTool(cachedPath, info); } core.info(`Installing version ${resolvedVersion} from GitHub`); - toolPath = await installAtmosVersion(info, auth, arch, installWrapper, checksumValidation); + let toolPath = await installAtmosVersion(info, auth, arch, installWrapper, checksumValidation); if (osPlat != "win32") { toolPath = external_path_.join(toolPath); } diff --git a/src/__fixtures__/github-releases.ts b/src/__fixtures__/github-releases.ts new file mode 100644 index 0000000..4df00cb --- /dev/null +++ b/src/__fixtures__/github-releases.ts @@ -0,0 +1,14 @@ +export const githubReleaseArm = { + tag_name: "v1.222.0", + prerelease: false, + assets: [ + { + name: "atmos_1.222.0_linux_arm64", + browser_download_url: "https://example.test/linux-arm64" + }, + { + name: "atmos_1.222.0_SHA256SUMS", + browser_download_url: "https://example.test/checksums" + } + ] +}; diff --git a/src/__tests__/setup-atmos.test.ts b/src/__tests__/setup-atmos.test.ts index 9aa1c17..2ad9835 100644 --- a/src/__tests__/setup-atmos.test.ts +++ b/src/__tests__/setup-atmos.test.ts @@ -5,13 +5,17 @@ import path from "path"; import * as core from "@actions/core"; import * as io from "@actions/io"; import * as tc from "@actions/tool-cache"; +import { RequestError } from "octokit"; +import { githubReleaseArm } from "../__fixtures__/github-releases"; import * as installer from "../installer"; import { IAtmosVersion, IAtmosVersionInfo } from "../interfaces"; import { run } from "../main"; import * as sys from "../system"; const mockPaginateIterator = jest.fn(); +const mockGetReleaseByTag = jest.fn(); +const mockGetLatestRelease = jest.fn(); jest.mock("@actions/core"); jest.mock("@actions/io"); @@ -23,10 +27,13 @@ jest.mock("octokit", () => ({ }, rest: { repos: { - listReleases: jest.fn() + listReleases: jest.fn(), + getReleaseByTag: mockGetReleaseByTag, + getLatestRelease: mockGetLatestRelease } } - })) + })), + RequestError: class RequestError extends Error {} })); const repoParent = path.resolve(__dirname, "..", "..", ".."); @@ -109,6 +116,8 @@ describe("Setup Atmos", () => { mockPaginateIterator.mockImplementation(async function* paginateReleases() { yield { data: [githubRelease] }; }); + mockGetReleaseByTag.mockResolvedValue({ data: githubRelease }); + mockGetLatestRelease.mockResolvedValue({ data: githubRelease }); }); afterEach(() => { @@ -184,19 +193,6 @@ describe("Setup Atmos", () => { expect(installer.findVersionMatch("latest", "arm64", releaseCandidates)).toBeUndefined(); }); - it("maps GitHub releases to binary assets plus checksum URLs", async () => { - const versions = await installer.getVersionsFromGitHubReleases(undefined); - - expect(versions).toEqual([ - { - name: "v1.222.0", - prerelease: false, - checksumsUrl: "https://example.test/checksums", - assets: releaseCandidates[0].assets - } - ]); - }); - it("returns resolved version info with checksum URL", async () => { mockPlatform("linux"); @@ -463,6 +459,85 @@ describe("Setup Atmos", () => { }); }); + describe("optimal cache usage", () => { + beforeEach(() => { + mockPlatform("linux"); + }); + + it("serves a cached exact version without any GitHub request", async () => { + jest.spyOn(tc, "find").mockReturnValue("/cache/atmos"); + + const { info } = await installer.getAtmos("1.222.0", undefined, "x64", false); + + expect(info?.resolvedVersion).toEqual("v1.222.0"); + expect(mockGetReleaseByTag).not.toHaveBeenCalled(); + expect(mockGetLatestRelease).not.toHaveBeenCalled(); + expect(mockPaginateIterator).not.toHaveBeenCalled(); + }); + + it("fetches an uncached exact version with a single tagged request", async () => { + jest.spyOn(tc, "find").mockReturnValue(""); + jest.spyOn(tc, "cacheDir").mockResolvedValue("/cache/atmos"); + setupInstallSpies("linux"); + + await installer.getAtmos("1.222.0", undefined, "x64", false, "skip"); + + expect(mockGetReleaseByTag).toHaveBeenCalledWith({ owner: "cloudposse", repo: "atmos", tag: "v1.222.0" }); + expect(mockPaginateIterator).not.toHaveBeenCalled(); + }); + + it("fetches `latest` with a single request", async () => { + await installer.getMatchingVersion("latest", undefined, "x64"); + + expect(mockGetLatestRelease).toHaveBeenCalledTimes(1); + expect(mockPaginateIterator).not.toHaveBeenCalled(); + }); + + it("stops paginating a range at the first matching page", async () => { + let pagesFetched = 0; + mockPaginateIterator.mockImplementation(async function* paginateReleases() { + pagesFetched++; + yield { data: [githubRelease] }; + pagesFetched++; + yield { data: [githubRelease] }; + }); + + await installer.getMatchingVersion("1.x", undefined, "x64"); + + expect(mockPaginateIterator).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ per_page: 100 })); + expect(pagesFetched).toBe(1); + }); + + it("falls back to pagination when a tagged lookup 404s", async () => { + mockGetReleaseByTag.mockRejectedValueOnce(Object.assign(Object.create(RequestError.prototype), { status: 404 })); + + const info = await installer.getMatchingVersion("1.222.0", undefined, "x64"); + + expect(mockPaginateIterator).toHaveBeenCalled(); + expect(info?.resolvedVersion).toEqual("v1.222.0"); + }); + + it("rethrows non-404 errors instead of paginating", async () => { + const error = Object.assign(Object.create(RequestError.prototype), { status: 403 }); + mockGetReleaseByTag.mockRejectedValueOnce(error); + + await expect(installer.getMatchingVersion("1.222.0", undefined, "x64")).rejects.toBe(error); + expect(mockPaginateIterator).not.toHaveBeenCalled(); + }); + + it("falls back to pagination when `latest` lacks a matching asset", async () => { + mockGetLatestRelease.mockResolvedValueOnce({ data: githubRelease }); + mockPaginateIterator.mockImplementation(async function* paginateReleases() { + yield { data: [githubReleaseArm] }; + }); + + const info = await installer.getMatchingVersion("latest", undefined, "arm64"); + + expect(mockPaginateIterator).toHaveBeenCalled(); + expect(info?.downloadUrl).toEqual("https://example.test/linux-arm64"); + }); + }); + describe("run", () => { it("sets the resolved version output", async () => { jest diff --git a/src/installer.ts b/src/installer.ts index fce8178..582ae79 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -6,7 +6,7 @@ import * as path from "path"; import * as core from "@actions/core"; import * as io from "@actions/io"; import * as tc from "@actions/tool-cache"; -import { Octokit } from "octokit"; +import { Octokit, RequestError } from "octokit"; import * as semver from "semver"; import { getAtmosBinaryName, getAtmosWrappedBinaryName } from "./atmos-bin"; @@ -195,44 +195,63 @@ export const findVersionMatch = ( return result; }; -export const getVersionsFromGitHubReleases = async (auth: string | undefined): Promise => { - const octokit = new Octokit({ auth }); - - const versions: IAtmosVersion[] = []; - for await (const release of octokit.paginate.iterator(octokit.rest.repos.listReleases, { - owner: "cloudposse", - repo: "atmos" - })) { - release.data.forEach((r) => { - const { tag_name, prerelease } = r; - if (!tag_name) { - throw new Error(`Release tag is empty`); - } +const createOctokit = (auth: string | undefined): Octokit => { + return new Octokit({ auth }); +}; - const assets = r.assets.flatMap((asset) => { - const { name, browser_download_url } = asset; - const atmosAsset = parseAtmosReleaseAsset(name, browser_download_url); +type GitHubRelease = Awaited>["data"]; - return atmosAsset ? [atmosAsset] : []; - }); - const checksumsUrl = r.assets.find( - (asset) => asset.name === getChecksumsAssetName(tag_name) - )?.browser_download_url; +const mapReleaseToAtmosVersion = (release: GitHubRelease): IAtmosVersion => { + const { prerelease, tag_name } = release; - const version: IAtmosVersion = { name: tag_name, prerelease, assets, checksumsUrl }; - versions.push(version); - }); + if (!tag_name) { + throw new Error(`Release tag is empty`); } - return versions; + + const assets = release.assets.flatMap((asset) => { + const { browser_download_url, name } = asset; + const atmosAsset = parseAtmosReleaseAsset(name, browser_download_url); + + return atmosAsset ? [atmosAsset] : []; + }); + + const checksumsUrl = release.assets.find( + (asset) => asset.name === getChecksumsAssetName(tag_name) + )?.browser_download_url; + + return { name: tag_name, prerelease, assets, checksumsUrl }; }; -export const getMatchingVersion = async ( +export const getReleaseByTag = async ( versionSpec: string, - auth: string | undefined, - arch: string -): Promise => { - const candidates: IAtmosVersion[] | null = await getVersionsFromGitHubReleases(auth); + auth: string | undefined +): Promise => { + const octokit = createOctokit(auth); + const tag = `v${semver.clean(versionSpec)}`; + try { + const { data } = await octokit.rest.repos.getReleaseByTag({ owner: "cloudposse", repo: "atmos", tag }); + return [mapReleaseToAtmosVersion(data)]; + } catch (e: unknown) { + if (e instanceof RequestError && e.status === 404) { + return null; + } + throw e; + } +}; + +export const getLatestRelease = async (auth: string | undefined): Promise => { + const octokit = createOctokit(auth); + + const { data } = await octokit.rest.repos.getLatestRelease({ owner: "cloudposse", repo: "atmos" }); + return [mapReleaseToAtmosVersion(data)]; +}; + +const buildVersionInfo = ( + versionSpec: string, + arch: string, + candidates: IAtmosVersion[] | null +): IAtmosVersionInfo | null => { const version: IAtmosVersion | undefined = findVersionMatch(versionSpec, arch, candidates); if (!version) { return null; @@ -246,6 +265,54 @@ export const getMatchingVersion = async ( }; }; +const resolveFromReleaseList = async ( + versionSpec: string, + auth: string | undefined, + arch: string +): Promise => { + const octokit = createOctokit(auth); + const seen: IAtmosVersion[] = []; + + for await (const page of octokit.paginate.iterator(octokit.rest.repos.listReleases, { + owner: "cloudposse", + repo: "atmos", + per_page: 100 + })) { + page.data.forEach((r) => seen.push(mapReleaseToAtmosVersion(r))); + + const info = buildVersionInfo(versionSpec, arch, seen); + if (info) { + return info; + } + } + + return null; +}; + +export const getMatchingVersion = async ( + versionSpec: string, + auth: string | undefined, + arch: string +): Promise => { + // Exact version: fetch just that release by tag. + if (semver.valid(versionSpec)) { + const candidates = await getReleaseByTag(versionSpec, auth); + const info = candidates && buildVersionInfo(versionSpec, arch, candidates); + if (info) { + return info; + } + } else if (versionSpec === "latest") { + // Latest: fetch just the newest release. + const info = buildVersionInfo(versionSpec, arch, await getLatestRelease(auth)); + if (info) { + return info; + } + } + + // Ranges, or a fallback when the targeted lookup above found no match: page through the releases. + return resolveFromReleaseList(versionSpec, auth, arch); +}; + export const installWrapperBin = async (atmosDownloadPath: string): Promise => { let source = ""; let destination = ""; @@ -283,8 +350,7 @@ export const installWrapperBin = async (atmosDownloadPath: string): Promise => { const osPlat: string = os.platform(); + const toolCacheName = getToolCacheName(installWrapper); core.info(`Attempting to download ${versionSpec}...`); + const useCachedTool = (cachedPath: string, resolved: IAtmosVersionInfo) => { + core.info(`Found in cache @ ${cachedPath}`); + configureInstalledPath(cachedPath, installWrapper); + + return { toolPath: cachedPath, info: resolved }; + }; + + if (semver.valid(versionSpec)) { + const cachedPath = tc.find(toolCacheName, versionSpec, arch); + if (cachedPath) { + return useCachedTool(cachedPath, { + downloadUrl: "", + resolvedVersion: `v${semver.clean(versionSpec)}`, + fileName: "" + }); + } + } + const info: IAtmosVersionInfo | null = await getMatchingVersion(versionSpec, auth, arch); if (!info) { throw new Error(`Unable to find atmos version '${versionSpec}' for platform ${osPlat} and architecture ${arch}.`); } const { resolvedVersion } = info; - const toolCacheName = getToolCacheName(installWrapper); - - // Check to see if the version is already in the local cache - let toolPath: string; - toolPath = tc.find(toolCacheName, resolvedVersion, arch); - - if (toolPath) { - core.info(`Found in cache @ ${toolPath}`); - configureInstalledPath(toolPath, installWrapper); - return { toolPath, info }; + const cachedPath = tc.find(toolCacheName, resolvedVersion, arch); + if (cachedPath) { + return useCachedTool(cachedPath, info); } core.info(`Installing version ${resolvedVersion} from GitHub`); - toolPath = await installAtmosVersion(info, auth, arch, installWrapper, checksumValidation); + let toolPath = await installAtmosVersion(info, auth, arch, installWrapper, checksumValidation); if (osPlat != "win32") { toolPath = path.join(toolPath);