From 9b8caf6c4176fc1ee61fbd902a17861593d1280d Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Sat, 11 Apr 2026 19:14:05 +0200 Subject: [PATCH] Add manifest-file input (#352) --- .github/workflows/test.yml | 26 + README.md | 42 +- __tests__/download/custom-manifest.ndjson | 1 + __tests__/download/download-version.test.ts | 509 ++++++++++++++++++++ __tests__/download/manifest.test.ts | 196 ++++++++ action.yml | 6 +- dist/ruff-action/index.cjs | Bin 1469515 -> 1345881 bytes package-lock.json | 3 +- package.json | 3 +- src/download/download-version.ts | 248 ++++++---- src/download/manifest.ts | 243 ++++++++++ src/download/variant-selection.ts | 39 ++ src/ruff-action.ts | 17 +- src/utils/constants.ts | 6 + src/utils/fetch.ts | 21 + src/utils/inputs.ts | 1 + 16 files changed, 1238 insertions(+), 123 deletions(-) create mode 100644 __tests__/download/custom-manifest.ndjson create mode 100644 __tests__/download/download-version.test.ts create mode 100644 __tests__/download/manifest.test.ts create mode 100644 src/download/manifest.ts create mode 100644 src/download/variant-selection.ts create mode 100644 src/utils/fetch.ts 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 790eebc43be06a44b077239f1932d32e3cf318a0..b9a859cd923216b33ef3be2a388f5d7279878831 100644 GIT binary patch delta 16571 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 delta 136101 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 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");