diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7686ec3..f17db8c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -431,6 +431,31 @@ jobs: env: RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }} + test-custom-manifest-file: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create test src + run: | + mkdir -p "${{ runner.temp }}/ruff-manifest-test" + printf 'print("hello")\n' > "${{ runner.temp }}/ruff-manifest-test/hello.py" + - name: Install from custom manifest file + id: ruff-action + uses: ./ + with: + src: ${{ runner.temp }}/ruff-manifest-test + manifest-file: "https://raw.githubusercontent.com/astral-sh/ruff-action/${{ github.ref }}/__tests__/download/custom-manifest.ndjson" + - name: Correct version gets installed + run: | + if [ "$RUFF_VERSION" != "0.15.10" ]; then + echo "Wrong ruff version: $RUFF_VERSION" + exit 1 + fi + env: + RUFF_VERSION: ${{ steps.ruff-action.outputs.ruff-version }} + all-tests-passed: runs-on: ubuntu-latest needs: @@ -456,6 +481,7 @@ jobs: - test-failure - test-multiple-src - test-parent-directory-pyproject + - test-custom-manifest-file if: always() steps: - name: All tests passed diff --git a/README.md b/README.md index bbd0bd2..0d14262 100644 --- a/README.md +++ b/README.md @@ -20,20 +20,25 @@ anything `ruff` can (ex, fix). - [Install a specific version](#install-a-specific-version) - [Install a version by supplying a semver range or pep440 specifier](#install-a-version-by-supplying-a-semver-range-or-pep440-specifier) - [Install a version from a specified version file](#install-a-version-from-a-specified-version-file) + - [Install using a custom manifest URL](#install-using-a-custom-manifest-url) - [Validate checksum](#validate-checksum) - [GitHub authentication token](#github-authentication-token) - [Outputs](#outputs) ## Usage -| Input | Description | Default | -|----------------|--------------------------------------------------------------------------------------------------------------------------------------------|--------------------| -| `version` | The version of Ruff to install. See [Install specific versions](#install-specific-versions) | `latest` | -| `version-file` | The file to read the version from. See [Install a version from a specified version file](#install-a-version-from-a-specified-version-file) | None | -| `args` | The arguments to pass to the `ruff` command. See [Configuring Ruff] | `check` | -| `src` | The directory or single files to run `ruff` on. | [github.workspace] | -| `checksum` | The sha256 checksum of the downloaded executable. | None | -| `github-token` | The GitHub token to use for authentication. | `GITHUB_TOKEN` | +| Input | Description | Default | +|-----------------|--------------------------------------------------------------------------------------------------------------------------------------------|--------------------| +| `version` | The version of Ruff to install. See [Install specific versions](#install-specific-versions) | `latest` | +| `version-file` | The file to read the version from. See [Install a version from a specified version file](#install-a-version-from-a-specified-version-file) | None | +| `manifest-file` | URL to a custom Ruff manifest in the `astral-sh/versions` format. | None | +| `args` | The arguments to pass to the `ruff` command. See [Configuring Ruff] | `check` | +| `src` | The directory or single files to run `ruff` on. | [github.workspace] | +| `checksum` | The sha256 checksum of the downloaded artifact. | None | +| `github-token` | The GitHub token to use when downloading Ruff release artifacts from GitHub. | `GITHUB_TOKEN` | + +By default, Ruff version metadata is resolved from the +[`astral-sh/versions` Ruff manifest](https://github.com/astral-sh/versions/blob/main/v1/ruff.ndjson). ### Basic @@ -155,6 +160,19 @@ Currently `pyproject.toml` and `requirements.txt` are supported. version-file: "my-path/to/pyproject.toml-or-requirements.txt" ``` +#### Install using a custom manifest URL + +You can override the default `astral-sh/versions` manifest with `manifest-file`. +This affects both version resolution and artifact selection. + +```yaml +- name: Install Ruff from a custom manifest + uses: astral-sh/ruff-action@v3 + with: + version: "latest" + manifest-file: "https://example.com/ruff.ndjson" +``` + ### Validate checksum You can specify a checksum to validate the downloaded executable. Checksums up to the default version @@ -171,9 +189,11 @@ are automatically verified by this action. The sha256 hashes can be found on the ### GitHub authentication token -This action uses the GitHub API to fetch the ruff release artifacts. To avoid hitting the GitHub API -rate limit too quickly, an authentication token can be provided via the `github-token` input. By -default, the `GITHUB_TOKEN` secret is used, which is automatically provided by GitHub Actions. +By default, this action resolves available uv versions from +[`astral-sh/versions`](https://github.com/astral-sh/versions) and downloads release artifacts from `https://releases.astral.sh`. If this fails this action falls back to downloading from the GitHub releases page of the ruff repository. + +You can provide a token via `github-token` to authenticate those downloads. By default, the +`GITHUB_TOKEN` secret is used, which is automatically provided by GitHub Actions. If the default [permissions for the GitHub token](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#permissions-for-the-github_token) diff --git a/__tests__/download/custom-manifest.ndjson b/__tests__/download/custom-manifest.ndjson new file mode 100644 index 0000000..abb2f05 --- /dev/null +++ b/__tests__/download/custom-manifest.ndjson @@ -0,0 +1 @@ +{"version":"0.15.10","artifacts":[{"platform":"x86_64-unknown-linux-gnu","variant":"default","url":"https://github.com/astral-sh/ruff/releases/download/0.15.10/ruff-x86_64-unknown-linux-gnu.tar.gz","archive_format":"tar.gz","sha256":"e3e9e5c791542f00d95edc74a506e1ac24efc0af9574de01ab338187bf1ff9f6"}]} diff --git a/__tests__/download/download-version.test.ts b/__tests__/download/download-version.test.ts new file mode 100644 index 0000000..950224a --- /dev/null +++ b/__tests__/download/download-version.test.ts @@ -0,0 +1,509 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import * as semver from "semver"; + +const mockInfo = jest.fn(); +const mockWarning = jest.fn(); + +jest.unstable_mockModule("@actions/core", () => ({ + debug: jest.fn(), + info: mockInfo, + warning: mockWarning, +})); + +const mockDownloadTool = jest.fn(); +const mockExtractTar = jest.fn(); +const mockExtractZip = jest.fn(); +const mockCacheDir = jest.fn(); + +jest.unstable_mockModule("@actions/tool-cache", () => ({ + cacheDir: mockCacheDir, + downloadTool: mockDownloadTool, + evaluateVersions: (versions: string[], range: string) => + semver.maxSatisfying(versions, range) ?? "", + extractTar: mockExtractTar, + extractZip: mockExtractZip, + find: () => "", + findAllVersions: () => [], + isExplicitVersion: (version: string) => semver.valid(version) !== null, +})); + +const mockGetLatestVersion = jest.fn(); +const mockGetAllVersions = jest.fn(); +const mockGetArtifact = jest.fn(); + +jest.unstable_mockModule("../../src/download/manifest", () => ({ + getAllVersions: mockGetAllVersions, + getArtifact: mockGetArtifact, + getLatestVersion: mockGetLatestVersion, +})); + +const mockValidateChecksum = jest.fn(); + +jest.unstable_mockModule("../../src/download/checksum/checksum", () => ({ + validateChecksum: mockValidateChecksum, +})); + +const mockCopyFile = jest.fn(); +const mockReaddir = jest.fn(); + +jest.unstable_mockModule("node:fs", () => ({ + promises: { + copyFile: mockCopyFile, + readdir: mockReaddir, + }, +})); + +const { downloadVersion, resolveVersion, rewriteToMirror } = await import( + "../../src/download/download-version" +); + +describe("download-version", () => { + beforeEach(() => { + mockInfo.mockReset(); + mockWarning.mockReset(); + mockDownloadTool.mockReset(); + mockExtractTar.mockReset(); + mockExtractZip.mockReset(); + mockCacheDir.mockReset(); + mockGetLatestVersion.mockReset(); + mockGetAllVersions.mockReset(); + mockGetArtifact.mockReset(); + mockValidateChecksum.mockReset(); + mockCopyFile.mockReset(); + mockReaddir.mockReset(); + + mockDownloadTool.mockResolvedValue("/tmp/downloaded"); + mockExtractTar.mockResolvedValue("/tmp/extracted"); + mockExtractZip.mockResolvedValue("/tmp/extracted"); + mockCacheDir.mockResolvedValue("/tmp/cached"); + mockReaddir.mockResolvedValue(["ruff"]); + }); + + describe("resolveVersion", () => { + it("uses the default manifest to resolve latest", async () => { + mockGetLatestVersion.mockResolvedValue("0.15.8"); + + const version = await resolveVersion("latest", undefined); + + expect(version).toBe("0.15.8"); + expect(mockGetLatestVersion).toHaveBeenCalledTimes(1); + expect(mockGetLatestVersion).toHaveBeenCalledWith(undefined); + }); + + it("uses the default manifest to resolve available versions", async () => { + mockGetAllVersions.mockResolvedValue(["0.15.8", "0.15.7"]); + + const version = await resolveVersion("0.15.x", undefined); + + expect(version).toBe("0.15.8"); + expect(mockGetAllVersions).toHaveBeenCalledTimes(1); + expect(mockGetAllVersions).toHaveBeenCalledWith(undefined); + }); + + it("uses manifest-file when provided", async () => { + mockGetAllVersions.mockResolvedValue(["0.15.8", "0.15.7"]); + + const version = await resolveVersion( + "0.15.x", + "https://example.com/custom.ndjson", + ); + + expect(version).toBe("0.15.8"); + expect(mockGetAllVersions).toHaveBeenCalledWith( + "https://example.com/custom.ndjson", + ); + }); + }); + + describe("downloadVersion", () => { + it("fails when manifest lookup fails", async () => { + mockGetArtifact.mockRejectedValue(new Error("manifest unavailable")); + + await expect( + downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + undefined, + "token", + ), + ).rejects.toThrow("manifest unavailable"); + + expect(mockDownloadTool).not.toHaveBeenCalled(); + expect(mockValidateChecksum).not.toHaveBeenCalled(); + }); + + it("fails when no matching artifact exists in the default manifest", async () => { + mockGetArtifact.mockResolvedValue(undefined); + + await expect( + downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + undefined, + "token", + ), + ).rejects.toThrow( + "Could not find artifact for version 0.15.8, arch x86_64, platform unknown-linux-gnu in https://raw.githubusercontent.com/astral-sh/versions/main/v1/ruff.ndjson .", + ); + + expect(mockDownloadTool).not.toHaveBeenCalled(); + expect(mockValidateChecksum).not.toHaveBeenCalled(); + }); + + it("uses built-in checksums for default manifest downloads", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "manifest-checksum-that-should-be-ignored", + downloadUrl: "https://example.com/ruff.tar.gz", + }); + + await downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + undefined, + "token", + ); + + expect(mockValidateChecksum).toHaveBeenCalledWith( + undefined, + "/tmp/downloaded", + "x86_64", + "unknown-linux-gnu", + "0.15.8", + ); + }); + + it("rewrites GitHub Releases URLs to the Astral mirror", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "abc123", + downloadUrl: + "https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz", + }); + + await downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + undefined, + "token", + ); + + expect(mockDownloadTool).toHaveBeenCalledWith( + "https://releases.astral.sh/github/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz", + undefined, + undefined, + ); + }); + + it("does not rewrite non-GitHub URLs", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "abc123", + downloadUrl: "https://example.com/ruff.tar.gz", + }); + + await downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + undefined, + "token", + ); + + expect(mockDownloadTool).toHaveBeenCalledWith( + "https://example.com/ruff.tar.gz", + undefined, + undefined, + ); + }); + + it("falls back to GitHub Releases when the mirror download fails", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "abc123", + downloadUrl: + "https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz", + }); + + mockDownloadTool + .mockRejectedValueOnce(new Error("mirror unavailable")) + .mockResolvedValueOnce("/tmp/downloaded"); + + await downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + undefined, + "token", + ); + + expect(mockDownloadTool).toHaveBeenCalledTimes(2); + expect(mockDownloadTool).toHaveBeenNthCalledWith( + 1, + "https://releases.astral.sh/github/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz", + undefined, + undefined, + ); + expect(mockDownloadTool).toHaveBeenNthCalledWith( + 2, + "https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz", + undefined, + "token", + ); + expect(mockWarning).toHaveBeenCalledWith( + "Failed to download from mirror, falling back to GitHub Releases: mirror unavailable", + ); + }); + + it("falls back to the canonical old GitHub Releases URL", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "abc123", + downloadUrl: + "https://github.com/astral-sh/ruff/releases/download/0.4.7/ruff-x86_64-unknown-linux-gnu.tar.gz", + }); + + mockDownloadTool + .mockRejectedValueOnce(new Error("mirror unavailable")) + .mockResolvedValueOnce("/tmp/downloaded"); + + await downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.4.7", + undefined, + "token", + ); + + expect(mockDownloadTool).toHaveBeenNthCalledWith( + 2, + "https://github.com/astral-sh/ruff/releases/download/v0.4.7/ruff-0.4.7-x86_64-unknown-linux-gnu.tar.gz", + undefined, + "token", + ); + }); + + it("does not fall back when checksum validation fails", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "abc123", + downloadUrl: + "https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz", + }); + mockValidateChecksum.mockRejectedValue(new Error("bad checksum")); + + await expect( + downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + undefined, + "token", + ), + ).rejects.toThrow("bad checksum"); + + expect(mockDownloadTool).toHaveBeenCalledTimes(1); + expect(mockWarning).not.toHaveBeenCalled(); + }); + + it("does not fall back when extraction fails", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "abc123", + downloadUrl: + "https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz", + }); + mockExtractTar.mockRejectedValue(new Error("extract failed")); + + await expect( + downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + undefined, + "token", + ), + ).rejects.toThrow("extract failed"); + + expect(mockDownloadTool).toHaveBeenCalledTimes(1); + expect(mockWarning).not.toHaveBeenCalled(); + }); + + it("does not fall back for non-GitHub URLs", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "abc123", + downloadUrl: "https://example.com/ruff.tar.gz", + }); + + mockDownloadTool.mockRejectedValue(new Error("download failed")); + + await expect( + downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + undefined, + "token", + ), + ).rejects.toThrow("download failed"); + + expect(mockDownloadTool).toHaveBeenCalledTimes(1); + }); + + it("uses manifest-file checksum metadata when checksum input is unset", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "manifest-checksum", + downloadUrl: "https://example.com/custom-ruff.tar.gz", + }); + + await downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + "", + "token", + "https://example.com/custom.ndjson", + ); + + expect(mockValidateChecksum).toHaveBeenCalledWith( + "manifest-checksum", + "/tmp/downloaded", + "x86_64", + "unknown-linux-gnu", + "0.15.8", + ); + }); + + it("prefers checksum input over manifest-file checksum metadata", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "manifest-checksum", + downloadUrl: "https://example.com/custom-ruff.tar.gz", + }); + + await downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + "user-checksum", + "token", + "https://example.com/custom.ndjson", + ); + + expect(mockValidateChecksum).toHaveBeenCalledWith( + "user-checksum", + "/tmp/downloaded", + "x86_64", + "unknown-linux-gnu", + "0.15.8", + ); + }); + + it("preserves tar extraction behavior for newer versions", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "abc123", + downloadUrl: "https://example.com/ruff.tar.gz", + }); + + await downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.15.8", + "user-checksum", + "token", + ); + + expect(mockExtractTar).toHaveBeenCalledWith("/tmp/downloaded"); + expect(mockCacheDir).toHaveBeenCalledWith( + "/tmp/extracted/ruff-x86_64-unknown-linux-gnu", + "ruff", + "0.15.8", + "x86_64", + ); + }); + + it("preserves tar extraction behavior for older versions", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "tar.gz", + checksum: "abc123", + downloadUrl: "https://example.com/ruff.tar.gz", + }); + + await downloadVersion( + "unknown-linux-gnu", + "x86_64", + "0.4.10", + undefined, + "token", + ); + + expect(mockCacheDir).toHaveBeenCalledWith( + "/tmp/extracted", + "ruff", + "0.4.10", + "x86_64", + ); + }); + + it("preserves zip extraction behavior on Windows", async () => { + mockGetArtifact.mockResolvedValue({ + archiveFormat: "zip", + checksum: "abc123", + downloadUrl: "https://example.com/ruff.zip", + }); + + await downloadVersion( + "pc-windows-msvc", + "x86_64", + "0.15.8", + undefined, + "token", + ); + + expect(mockCopyFile).toHaveBeenCalledWith( + "/tmp/downloaded", + "/tmp/downloaded.zip", + ); + expect(mockExtractZip).toHaveBeenCalledWith("/tmp/downloaded.zip"); + expect(mockCacheDir).toHaveBeenCalledWith( + "/tmp/extracted", + "ruff", + "0.15.8", + "x86_64", + ); + }); + }); + + describe("rewriteToMirror", () => { + it("rewrites a GitHub Releases URL to the Astral mirror", () => { + expect( + rewriteToMirror( + "https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz", + ), + ).toBe( + "https://releases.astral.sh/github/ruff/releases/download/0.15.8/ruff-x86_64-unknown-linux-gnu.tar.gz", + ); + }); + + it("returns undefined for non-GitHub URLs", () => { + expect( + rewriteToMirror("https://example.com/ruff.tar.gz"), + ).toBeUndefined(); + }); + + it("returns undefined for a different GitHub repo", () => { + expect( + rewriteToMirror( + "https://github.com/other/repo/releases/download/v1.0/file.tar.gz", + ), + ).toBeUndefined(); + }); + }); +}); diff --git a/__tests__/download/manifest.test.ts b/__tests__/download/manifest.test.ts new file mode 100644 index 0000000..c9128d0 --- /dev/null +++ b/__tests__/download/manifest.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +const mockFetch = jest.fn(); + +jest.unstable_mockModule("@actions/core", () => ({ + debug: jest.fn(), + info: jest.fn(), +})); + +jest.unstable_mockModule("../../src/utils/fetch", () => ({ + fetch: mockFetch, +})); + +const { + clearManifestCache, + fetchManifest, + getAllVersions, + getArtifact, + getLatestVersion, + parseManifest, +} = await import("../../src/download/manifest"); + +const sampleManifestResponse = `{"version":"0.15.8","artifacts":[{"platform":"aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-aarch64-apple-darwin.tar.gz","archive_format":"tar.gz","sha256":"fcf0a9ea6599c6ae28a4c854ac6da76f2c889354d7c36ce136ef071f7ab9721f"},{"platform":"x86_64-pc-windows-msvc","variant":"default","url":"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-pc-windows-msvc.zip","archive_format":"zip","sha256":"eb02fd95d8e0eed462b4a67ecdd320d865b38c560bffcda9a0b87ec944bdf036"}]} +{"version":"0.15.7","artifacts":[{"platform":"aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/ruff/releases/download/0.15.7/ruff-aarch64-apple-darwin.tar.gz","archive_format":"tar.gz","sha256":"606b3c6949d971709f2526fa0d9f0fd23ccf60e09f117999b406b424af18a6a6"}]}`; + +const multiVariantManifestResponse = `{"version":"0.15.8","artifacts":[{"platform":"aarch64-apple-darwin","variant":"python-managed","url":"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-aarch64-apple-darwin-managed.tar.gz","archive_format":"tar.gz","sha256":"managed-checksum"},{"platform":"aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-aarch64-apple-darwin.zip","archive_format":"zip","sha256":"default-checksum"}]}`; + +const oldVersionManifestResponse = `{"version":"v0.4.7","artifacts":[{"platform":"0.4.7-aarch64-apple-darwin","variant":"default","url":"https://github.com/astral-sh/ruff/releases/download/v0.4.7/ruff-0.4.7-aarch64-apple-darwin.tar.gz","archive_format":"tar.gz","sha256":"old-checksum"}]}`; + +function createMockResponse( + ok: boolean, + status: number, + statusText: string, + data: string, +) { + return { + ok, + status, + statusText, + text: async () => data, + }; +} + +describe("manifest", () => { + beforeEach(() => { + clearManifestCache(); + mockFetch.mockReset(); + }); + + describe("fetchManifest", () => { + it("fetches and parses manifest data", async () => { + mockFetch.mockResolvedValue( + createMockResponse(true, 200, "OK", sampleManifestResponse), + ); + + const versions = await fetchManifest(); + + expect(versions).toHaveLength(2); + expect(versions[0]?.version).toBe("0.15.8"); + expect(versions[1]?.version).toBe("0.15.7"); + }); + + it("throws on a failed fetch", async () => { + mockFetch.mockResolvedValue( + createMockResponse(false, 500, "Internal Server Error", ""), + ); + + await expect(fetchManifest()).rejects.toThrow( + "Failed to fetch manifest data: 500 Internal Server Error", + ); + }); + + it("caches results per URL", async () => { + mockFetch.mockResolvedValue( + createMockResponse(true, 200, "OK", sampleManifestResponse), + ); + + await fetchManifest("https://example.com/custom.ndjson"); + await fetchManifest("https://example.com/custom.ndjson"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe("getAllVersions", () => { + it("returns all version strings", async () => { + mockFetch.mockResolvedValue( + createMockResponse(true, 200, "OK", sampleManifestResponse), + ); + + const versions = await getAllVersions( + "https://example.com/custom.ndjson", + ); + + expect(versions).toEqual(["0.15.8", "0.15.7"]); + }); + }); + + describe("getLatestVersion", () => { + it("returns the first version string", async () => { + mockFetch.mockResolvedValue( + createMockResponse(true, 200, "OK", sampleManifestResponse), + ); + + await expect( + getLatestVersion("https://example.com/custom.ndjson"), + ).resolves.toBe("0.15.8"); + }); + }); + + describe("getArtifact", () => { + beforeEach(() => { + mockFetch.mockResolvedValue( + createMockResponse(true, 200, "OK", sampleManifestResponse), + ); + }); + + it("finds an artifact by version and platform", async () => { + const artifact = await getArtifact("0.15.8", "aarch64", "apple-darwin"); + + expect(artifact).toEqual({ + archiveFormat: "tar.gz", + checksum: + "fcf0a9ea6599c6ae28a4c854ac6da76f2c889354d7c36ce136ef071f7ab9721f", + downloadUrl: + "https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-aarch64-apple-darwin.tar.gz", + }); + }); + + it("finds a windows artifact", async () => { + const artifact = await getArtifact("0.15.8", "x86_64", "pc-windows-msvc"); + + expect(artifact).toEqual({ + archiveFormat: "zip", + checksum: + "eb02fd95d8e0eed462b4a67ecdd320d865b38c560bffcda9a0b87ec944bdf036", + downloadUrl: + "https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-x86_64-pc-windows-msvc.zip", + }); + }); + + it("prefers the default variant when multiple artifacts share a platform", async () => { + mockFetch.mockResolvedValue( + createMockResponse(true, 200, "OK", multiVariantManifestResponse), + ); + + const artifact = await getArtifact("0.15.8", "aarch64", "apple-darwin"); + + expect(artifact).toEqual({ + archiveFormat: "zip", + checksum: "default-checksum", + downloadUrl: + "https://github.com/astral-sh/ruff/releases/download/0.15.8/ruff-aarch64-apple-darwin.zip", + }); + }); + + it("finds an old artifact when the manifest version has a v prefix", async () => { + mockFetch.mockResolvedValue( + createMockResponse(true, 200, "OK", oldVersionManifestResponse), + ); + + const artifact = await getArtifact("0.4.7", "aarch64", "apple-darwin"); + + expect(artifact).toEqual({ + archiveFormat: "tar.gz", + checksum: "old-checksum", + downloadUrl: + "https://github.com/astral-sh/ruff/releases/download/v0.4.7/ruff-0.4.7-aarch64-apple-darwin.tar.gz", + }); + }); + + it("returns undefined for an unknown version", async () => { + const artifact = await getArtifact("0.0.1", "aarch64", "apple-darwin"); + + expect(artifact).toBeUndefined(); + }); + + it("returns undefined for an unknown platform", async () => { + const artifact = await getArtifact( + "0.15.8", + "aarch64", + "unknown-linux-musl", + ); + + expect(artifact).toBeUndefined(); + }); + }); + + describe("parseManifest", () => { + it("throws for malformed manifest data", () => { + expect(() => parseManifest('{"version":"0.1.0"', "test-source")).toThrow( + "Failed to parse manifest data from test-source", + ); + }); + }); +}); diff --git a/action.yml b/action.yml index a72f645..4e47051 100644 --- a/action.yml +++ b/action.yml @@ -20,10 +20,12 @@ inputs: checksum: description: "The checksum of the ruff version to install" required: false + manifest-file: + description: "URL to a custom manifest file in the astral-sh/versions format." + required: false github-token: description: - "Used to increase the rate limit when retrieving versions and downloading - ruff." + "Used for authenticated downloads of Ruff release artifacts from GitHub." required: false default: ${{ github.token }} outputs: diff --git a/dist/ruff-action/index.cjs b/dist/ruff-action/index.cjs index 790eebc..b9a859c 100644 Binary files a/dist/ruff-action/index.cjs and b/dist/ruff-action/index.cjs differ diff --git a/package-lock.json b/package-lock.json index 703e8bc..37e2ec1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "@octokit/plugin-paginate-rest": "^13.1.1", "@octokit/plugin-rest-endpoint-methods": "^16.0.0", "@renovatebot/pep440": "^4.2.1", - "smol-toml": "^1.6.0" + "smol-toml": "^1.6.0", + "undici": "^6.24.1" }, "devDependencies": { "@biomejs/biome": "^2.4.7", diff --git a/package.json b/package.json index 1368edf..3425847 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "@octokit/plugin-paginate-rest": "^13.1.1", "@octokit/plugin-rest-endpoint-methods": "^16.0.0", "@renovatebot/pep440": "^4.2.1", - "smol-toml": "^1.6.0" + "smol-toml": "^1.6.0", + "undici": "^6.24.1" }, "devDependencies": { "@biomejs/biome": "^2.4.7", diff --git a/src/download/download-version.ts b/src/download/download-version.ts index ecd269d..283cef7 100644 --- a/src/download/download-version.ts +++ b/src/download/download-version.ts @@ -2,16 +2,17 @@ import { promises as fs } from "node:fs"; import * as path from "node:path"; import * as core from "@actions/core"; import * as tc from "@actions/tool-cache"; -import { Octokit } from "@octokit/core"; -import { paginateRest } from "@octokit/plugin-paginate-rest"; -import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods"; import * as pep440 from "@renovatebot/pep440"; import * as semver from "semver"; -import { OWNER, REPO, TOOL_CACHE_NAME } from "../utils/constants"; +import { + ASTRAL_MIRROR_PREFIX, + GITHUB_RELEASES_PREFIX, + TOOL_CACHE_NAME, + VERSIONS_MANIFEST_URL, +} from "../utils/constants"; import type { Architecture, Platform } from "../utils/platforms"; import { validateChecksum } from "./checksum/checksum"; - -const PaginatingOctokit = Octokit.plugin(paginateRest, restEndpointMethods); +import { getAllVersions, getArtifact, getLatestVersion } from "./manifest"; export function tryGetFromToolCache( arch: Architecture, @@ -32,31 +33,46 @@ export async function downloadVersion( platform: Platform, arch: Architecture, version: string, - checkSum: string | undefined, + checksum: string | undefined, githubToken: string, + manifestUrl?: string, ): Promise<{ version: string; cachedToolDir: string }> { - const artifact = `ruff-${arch}-${platform}`; - let extension = ".tar.gz"; - if (platform === "pc-windows-msvc") { - extension = ".zip"; - } - const downloadUrl = constructDownloadUrl(version, platform, arch); - core.debug(`Downloading ruff from "${downloadUrl}" ...`); + const artifact = await getArtifact(version, arch, platform, manifestUrl); - const downloadPath = await tc.downloadTool( - downloadUrl, - undefined, - githubToken, + if (!artifact) { + throw new Error( + getMissingArtifactMessage(version, arch, platform, manifestUrl), + ); + } + + // For the default astral-sh/versions source, checksum validation relies on + // user input or the built-in KNOWN_CHECKSUMS table, not manifest sha256 values. + const resolvedChecksum = + manifestUrl === undefined + ? checksum + : resolveChecksum(checksum, artifact.checksum); + + const downloadPath = await downloadArtifact( + artifact.downloadUrl, + platform, + arch, + version, + getDownloadToken(artifact.downloadUrl, githubToken), + ); + await validateChecksum( + resolvedChecksum, + downloadPath, + arch, + platform, + version, ); - core.debug(`Downloaded ruff to "${downloadPath}"`); - await validateChecksum(checkSum, downloadPath, arch, platform, version); const extractedDir = await extractDownloadedArtifact( version, downloadPath, - extension, + getExtension(platform), platform, - artifact, + `ruff-${arch}-${platform}`, ); const cachedToolDir = await tc.cacheDir( @@ -65,25 +81,60 @@ export async function downloadVersion( version, arch, ); - return { cachedToolDir, version: version }; + return { cachedToolDir, version }; } -function constructDownloadUrl( - version: string, +export function rewriteToMirror(url: string): string | undefined { + if (!url.startsWith(GITHUB_RELEASES_PREFIX)) { + return undefined; + } + + return ASTRAL_MIRROR_PREFIX + url.slice(GITHUB_RELEASES_PREFIX.length); +} + +async function downloadArtifact( + downloadUrl: string, platform: Platform, arch: Architecture, -): string { - const artifactVersionSuffix = - semver.lte(version, "v0.4.10") && semver.gte(version, "v0.1.8") - ? `-${version}` - : ""; - const artifact = `ruff${artifactVersionSuffix}-${arch}-${platform}`; - let extension = ".tar.gz"; - if (platform === "pc-windows-msvc") { - extension = ".zip"; + version: string, + githubToken: string | undefined, +): Promise { + const mirrorUrl = rewriteToMirror(downloadUrl); + const resolvedDownloadUrl = mirrorUrl ?? downloadUrl; + + try { + return await downloadFile( + resolvedDownloadUrl, + mirrorUrl !== undefined ? undefined : githubToken, + ); + } catch (err) { + if (mirrorUrl === undefined) { + throw err; + } + + core.warning( + `Failed to download from mirror, falling back to GitHub Releases: ${(err as Error).message}`, + ); + + return await downloadFile( + constructDownloadUrl(version, platform, arch), + githubToken, + ); } - const versionPrefix = semver.lte(version, "v0.4.10") ? "v" : ""; - return `https://github.com/${OWNER}/${REPO}/releases/download/${versionPrefix}${version}/${artifact}${extension}`; +} + +async function downloadFile( + downloadUrl: string, + githubToken: string | undefined, +): Promise { + core.info(`Downloading ruff from "${downloadUrl}" ...`); + const downloadPath = await tc.downloadTool( + downloadUrl, + undefined, + githubToken, + ); + core.debug(`Downloaded ruff to "${downloadPath}"`); + return downloadPath; } async function extractDownloadedArtifact( @@ -98,7 +149,7 @@ async function extractDownloadedArtifact( const fullPathWithExtension = `${downloadPath}${extension}`; await fs.copyFile(downloadPath, fullPathWithExtension); ruffDir = await tc.extractZip(fullPathWithExtension); - // On windows extracting the zip does not create an intermediate directory + // On windows extracting the zip does not create an intermediate directory. } else { ruffDir = await tc.extractTar(downloadPath); if (semver.gte(version, "v0.5.0")) { @@ -113,18 +164,21 @@ async function extractDownloadedArtifact( export async function resolveVersion( versionInput: string, - githubToken: string, + manifestUrl?: string, ): Promise { core.debug(`Resolving ${versionInput}...`); + const version = versionInput === "latest" - ? await getLatestVersion(githubToken) + ? await getLatestVersion(manifestUrl) : versionInput; + if (tc.isExplicitVersion(version)) { core.debug(`Version ${version} is an explicit version.`); return version; } - const availableVersions = await getAvailableVersions(githubToken); + + const availableVersions = await getAvailableVersions(manifestUrl); const resolvedVersion = maxSatisfying(availableVersions, version); if (resolvedVersion === undefined) { throw new Error(`No version found for ${version}`); @@ -133,77 +187,63 @@ export async function resolveVersion( return resolvedVersion; } -async function getAvailableVersions(githubToken: string): Promise { - try { - const octokit = new PaginatingOctokit({ - auth: githubToken, - }); - return await getReleaseTagNames(octokit); - } catch (err) { - if ((err as Error).message.includes("Bad credentials")) { - core.info( - "No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.", - ); - const octokit = new PaginatingOctokit(); - return await getReleaseTagNames(octokit); - } - throw err; - } +async function getAvailableVersions(manifestUrl?: string): Promise { + return await getAllVersions(manifestUrl); } -async function getReleaseTagNames( - octokit: InstanceType, -): Promise { - const response = await octokit.paginate(octokit.rest.repos.listReleases, { - owner: OWNER, - repo: REPO, - }); - const releaseTagNames = response.map((release) => release.tag_name); - if (releaseTagNames.length === 0) { - throw Error( - "Github API request failed while getting releases. Check the GitHub status page for outages. Try again later.", - ); +function getMissingArtifactMessage( + version: string, + arch: Architecture, + platform: Platform, + manifestUrl?: string, +): string { + if (manifestUrl === undefined) { + return `Could not find artifact for version ${version}, arch ${arch}, platform ${platform} in ${VERSIONS_MANIFEST_URL} .`; } - return response.map((release) => release.tag_name); + + return `manifest-file does not contain version ${version}, arch ${arch}, platform ${platform}.`; } -async function getLatestVersion(githubToken: string) { - const octokit = new PaginatingOctokit({ - auth: githubToken, - }); - - let latestRelease: { tag_name: string } | undefined; - try { - latestRelease = await getLatestRelease(octokit); - } catch (err) { - if ((err as Error).message.includes("Bad credentials")) { - core.info( - "No (valid) GitHub token provided. Falling back to anonymous. Requests might be rate limited.", - ); - const octokit = new PaginatingOctokit(); - latestRelease = await getLatestRelease(octokit); - } else { - core.error( - "Github API request failed while getting latest release. Check the GitHub status page for outages. Try again later.", - ); - throw err; - } - } - - if (!latestRelease) { - throw new Error("Could not determine latest release."); - } - return latestRelease.tag_name; +function resolveChecksum( + checksum: string | undefined, + manifestChecksum: string, +): string { + return checksum !== undefined && checksum !== "" + ? checksum + : manifestChecksum; } -async function getLatestRelease( - octokit: InstanceType, -) { - const { data: latestRelease } = await octokit.rest.repos.getLatestRelease({ - owner: OWNER, - repo: REPO, - }); - return latestRelease; +function getDownloadToken( + downloadUrl: string, + githubToken: string, +): string | undefined { + return downloadUrl.startsWith(GITHUB_RELEASES_PREFIX) + ? githubToken + : undefined; +} + +function constructDownloadUrl( + version: string, + platform: Platform, + arch: Architecture, +): string { + const normalizedVersion = stripVersionPrefix(version); + const artifactVersionSuffix = + semver.lte(version, "v0.4.10") && semver.gte(version, "v0.1.8") + ? `-${normalizedVersion}` + : ""; + const artifact = `ruff${artifactVersionSuffix}-${arch}-${platform}`; + const versionPrefix = semver.lte(version, "v0.4.10") ? "v" : ""; + + return `${GITHUB_RELEASES_PREFIX}${versionPrefix}${normalizedVersion}/${artifact}${getExtension(platform)}`; +} + +function stripVersionPrefix(version: string): string { + return version.startsWith("v") ? version.slice(1) : version; +} + +function getExtension(platform: Platform): string { + return platform === "pc-windows-msvc" ? ".zip" : ".tar.gz"; } function maxSatisfying( diff --git a/src/download/manifest.ts b/src/download/manifest.ts new file mode 100644 index 0000000..e79569a --- /dev/null +++ b/src/download/manifest.ts @@ -0,0 +1,243 @@ +import * as core from "@actions/core"; +import { VERSIONS_MANIFEST_URL } from "../utils/constants"; +import { fetch } from "../utils/fetch"; +import { selectDefaultVariant } from "./variant-selection"; + +export interface ManifestArtifact { + platform: string; + variant?: string; + url: string; + archive_format: string; + sha256: string; +} + +export interface ManifestVersion { + version: string; + artifacts: ManifestArtifact[]; +} + +export interface ArtifactResult { + archiveFormat: string; + checksum: string; + downloadUrl: string; +} + +const cachedManifestData = new Map(); + +export async function fetchManifest( + manifestUrl: string = VERSIONS_MANIFEST_URL, +): Promise { + const cachedVersions = cachedManifestData.get(manifestUrl); + if (cachedVersions !== undefined) { + core.debug(`Using cached manifest data from ${manifestUrl}`); + return cachedVersions; + } + + core.info(`Fetching manifest data from ${manifestUrl} ...`); + const response = await fetch(manifestUrl, {}); + if (!response.ok) { + throw new Error( + `Failed to fetch manifest data: ${response.status} ${response.statusText}`, + ); + } + + const body = await response.text(); + const versions = parseManifest(body, manifestUrl); + cachedManifestData.set(manifestUrl, versions); + return versions; +} + +export function parseManifest( + data: string, + sourceDescription: string, +): ManifestVersion[] { + const trimmed = data.trim(); + if (trimmed === "") { + throw new Error(`Manifest at ${sourceDescription} is empty.`); + } + + if (trimmed.startsWith("[")) { + throw new Error( + `Legacy JSON array manifests are no longer supported in ${sourceDescription}. Use the astral-sh/versions manifest format instead.`, + ); + } + + const versions: ManifestVersion[] = []; + + for (const [index, line] of data.split("\n").entries()) { + const record = line.trim(); + if (record === "") { + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(record); + } catch (error) { + throw new Error( + `Failed to parse manifest data from ${sourceDescription} at line ${index + 1}: ${(error as Error).message}`, + ); + } + + if (!isManifestVersion(parsed)) { + throw new Error( + `Invalid manifest record in ${sourceDescription} at line ${index + 1}.`, + ); + } + + versions.push(parsed); + } + + if (versions.length === 0) { + throw new Error(`No manifest data found in ${sourceDescription}.`); + } + + return versions; +} + +export async function getLatestVersion( + manifestUrl: string = VERSIONS_MANIFEST_URL, +): Promise { + const latestVersion = (await fetchManifest(manifestUrl))[0]?.version; + + if (latestVersion === undefined) { + throw new Error("No versions found in manifest data"); + } + + core.debug(`Latest version from manifest: ${latestVersion}`); + return latestVersion; +} + +export async function getAllVersions( + manifestUrl: string = VERSIONS_MANIFEST_URL, +): Promise { + core.info( + `Getting available versions from ${manifestSource(manifestUrl)} ...`, + ); + const versions = await fetchManifest(manifestUrl); + return versions.map((versionData) => versionData.version); +} + +export async function getArtifact( + version: string, + arch: string, + platform: string, + manifestUrl: string = VERSIONS_MANIFEST_URL, +): Promise { + const versions = await fetchManifest(manifestUrl); + const versionData = versions.find((candidate) => + matchesManifestVersion(candidate.version, version), + ); + if (!versionData) { + core.debug(`Version ${version} not found in manifest ${manifestUrl}`); + return undefined; + } + + const targetPlatforms = getTargetPlatforms( + versionData.version, + arch, + platform, + ); + const matchingArtifacts = versionData.artifacts.filter((candidate) => + targetPlatforms.includes(candidate.platform), + ); + + if (matchingArtifacts.length === 0) { + core.debug( + `Artifact for ${targetPlatforms.join(" or ")} not found in version ${version}. Available platforms: ${versionData.artifacts + .map((candidate) => candidate.platform) + .join(", ")}`, + ); + return undefined; + } + + const artifact = selectDefaultVariant( + matchingArtifacts, + `Multiple artifacts found for ${targetPlatforms.join(" or ")} in version ${version}`, + ); + + return { + archiveFormat: artifact.archive_format, + checksum: artifact.sha256, + downloadUrl: artifact.url, + }; +} + +export function clearManifestCache(manifestUrl?: string): void { + if (manifestUrl === undefined) { + cachedManifestData.clear(); + return; + } + + cachedManifestData.delete(manifestUrl); +} + +function manifestSource(manifestUrl: string): string { + if (manifestUrl === VERSIONS_MANIFEST_URL) { + return VERSIONS_MANIFEST_URL; + } + + return `manifest-file ${manifestUrl}`; +} + +function matchesManifestVersion( + manifestVersion: string, + requestedVersion: string, +): boolean { + return ( + manifestVersion === requestedVersion || + manifestVersion === withVersionPrefix(requestedVersion) + ); +} + +function getTargetPlatforms( + manifestVersion: string, + arch: string, + platform: string, +): string[] { + const targetPlatform = `${arch}-${platform}`; + const versionPrefixedTargetPlatform = `${stripVersionPrefix(manifestVersion)}-${targetPlatform}`; + + return [targetPlatform, versionPrefixedTargetPlatform]; +} + +function withVersionPrefix(version: string): string { + return version.startsWith("v") ? version : `v${version}`; +} + +function stripVersionPrefix(version: string): string { + return version.startsWith("v") ? version.slice(1) : version; +} + +function isManifestVersion(value: unknown): value is ManifestVersion { + if (!isRecord(value)) { + return false; + } + + if (typeof value.version !== "string" || !Array.isArray(value.artifacts)) { + return false; + } + + return value.artifacts.every(isManifestArtifact); +} + +function isManifestArtifact(value: unknown): value is ManifestArtifact { + if (!isRecord(value)) { + return false; + } + + const variantIsValid = + typeof value.variant === "string" || value.variant === undefined; + + return ( + typeof value.archive_format === "string" && + typeof value.platform === "string" && + typeof value.sha256 === "string" && + typeof value.url === "string" && + variantIsValid + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/download/variant-selection.ts b/src/download/variant-selection.ts new file mode 100644 index 0000000..622fee2 --- /dev/null +++ b/src/download/variant-selection.ts @@ -0,0 +1,39 @@ +interface VariantAwareEntry { + variant?: string; +} + +export function selectDefaultVariant( + entries: T[], + duplicateEntryDescription: string, +): T { + const firstEntry = entries[0]; + if (firstEntry === undefined) { + throw new Error("selectDefaultVariant requires at least one candidate."); + } + + if (entries.length === 1) { + return firstEntry; + } + + const defaultEntries = entries.filter((entry) => + isDefaultVariant(entry.variant), + ); + if (defaultEntries.length === 1) { + return defaultEntries[0]; + } + + throw new Error( + `${duplicateEntryDescription} with variants ${formatVariants(entries)}. ruff-action currently requires a single default variant for duplicate platform entries.`, + ); +} + +function isDefaultVariant(variant: string | undefined): boolean { + return variant === undefined || variant === "default"; +} + +function formatVariants(entries: T[]): string { + return entries + .map((entry) => entry.variant ?? "default") + .sort((left, right) => left.localeCompare(right)) + .join(", "); +} diff --git a/src/ruff-action.ts b/src/ruff-action.ts index 4e659d3..68d873a 100644 --- a/src/ruff-action.ts +++ b/src/ruff-action.ts @@ -11,6 +11,7 @@ import { args, checkSum, githubToken, + manifestFile, src, version, versionFile as versionFileInput, @@ -62,6 +63,7 @@ async function setupRuff( githubToken: string, ): Promise<{ ruffDir: string; version: string }> { const resolvedVersion = await determineVersion(); + const manifestUrl = manifestFile || undefined; if (semver.lt(resolvedVersion, "v0.0.247")) { throw Error( "This action does not support ruff versions older than 0.0.247", @@ -82,6 +84,7 @@ async function setupRuff( resolvedVersion, checkSum, githubToken, + manifestUrl, ); return { @@ -95,7 +98,7 @@ async function determineVersion(): Promise { throw Error("It is not allowed to specify both version and version-file"); } if (version !== "") { - return await resolveVersion(version, githubToken); + return await resolveVersion(version, manifestFile || undefined); } if (versionFileInput !== "") { const versionFromPyproject = @@ -105,7 +108,10 @@ async function determineVersion(): Promise { `Could not parse version from ${versionFileInput}. Using latest version.`, ); } - return await resolveVersion(versionFromPyproject || "latest", githubToken); + return await resolveVersion( + versionFromPyproject || "latest", + manifestFile || undefined, + ); } const pyProjectPath = findPyprojectToml( src, @@ -113,7 +119,7 @@ async function determineVersion(): Promise { ); if (!pyProjectPath) { core.info(`Could not find pyproject.toml. Using latest version.`); - return await resolveVersion("latest", githubToken); + return await resolveVersion("latest", manifestFile || undefined); } const versionFromPyproject = getRuffVersionFromRequirementsFile(pyProjectPath); @@ -122,7 +128,10 @@ async function determineVersion(): Promise { `Could not parse version from ${pyProjectPath}. Using latest version.`, ); } - return await resolveVersion(versionFromPyproject || "latest", githubToken); + return await resolveVersion( + versionFromPyproject || "latest", + manifestFile || undefined, + ); } function addRuffToPath(cachedPath: string): void { diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 35e0414..bf0f794 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,3 +1,9 @@ export const REPO = "ruff"; export const OWNER = "astral-sh"; export const TOOL_CACHE_NAME = "ruff"; +export const VERSIONS_MANIFEST_URL = + "https://raw.githubusercontent.com/astral-sh/versions/main/v1/ruff.ndjson"; +export const GITHUB_RELEASES_PREFIX = + "https://github.com/astral-sh/ruff/releases/download/"; +export const ASTRAL_MIRROR_PREFIX = + "https://releases.astral.sh/github/ruff/releases/download/"; diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts new file mode 100644 index 0000000..0830dfd --- /dev/null +++ b/src/utils/fetch.ts @@ -0,0 +1,21 @@ +import { ProxyAgent, type RequestInit, fetch as undiciFetch } from "undici"; + +export function getProxyAgent() { + const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy; + if (httpProxy) { + return new ProxyAgent(httpProxy); + } + + const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; + if (httpsProxy) { + return new ProxyAgent(httpsProxy); + } + + return undefined; +} + +export const fetch = async (url: string, opts: RequestInit) => + await undiciFetch(url, { + dispatcher: getProxyAgent(), + ...opts, + }); diff --git a/src/utils/inputs.ts b/src/utils/inputs.ts index 6b59b93..ec4c1fa 100644 --- a/src/utils/inputs.ts +++ b/src/utils/inputs.ts @@ -6,3 +6,4 @@ export const githubToken = core.getInput("github-token"); export const args = core.getInput("args"); export const src = core.getInput("src"); export const versionFile = core.getInput("version-file"); +export const manifestFile = core.getInput("manifest-file");