diff --git a/.changeset/damp-dogs-earn.md b/.changeset/damp-dogs-earn.md new file mode 100644 index 000000000..faeaecee0 --- /dev/null +++ b/.changeset/damp-dogs-earn.md @@ -0,0 +1,5 @@ +--- +"go-mod-validator": minor +--- + +feat: repo-sha-exceptions input for allow specific repository commits as exceptions diff --git a/apps/go-mod-validator/README.md b/apps/go-mod-validator/README.md index b13c05307..7c754c647 100644 --- a/apps/go-mod-validator/README.md +++ b/apps/go-mod-validator/README.md @@ -21,11 +21,18 @@ Requirements: `${{ github.workspace }}` - `dep-prefix`: Prefix to filter dependencies to check. By default, we use `github.com/smartcontractkit` -- `repo-branch-exceptions` - Input allowing exceptions for non-default branches - on certain repositories. +- `repo-branch-exceptions` - Long-lived exceptions allowing specific branches as + valid references for certain repositories (e.g. release branches). - The input is newline delimited, in the format of: - `/:,` - Example: `smartcontractkit/.github:develop` +- `repo-sha-exceptions` - Short-lived exceptions allowing specific commit SHAs + as valid references for certain repositories (e.g. hotfixes, pre-merge pins). + Only applies to pseudo-version dependencies (commit-pinned). SHA matching is + prefix-based, so a 12-char abbreviated SHA matches a full 40-char SHA. + - The input is newline delimited, in the format of: + - `/:,` + - Example: `smartcontractkit/chainlink:abcdefabcdef` ## Outputs diff --git a/apps/go-mod-validator/action.yaml b/apps/go-mod-validator/action.yaml index 50f7eb32f..951e139e0 100644 --- a/apps/go-mod-validator/action.yaml +++ b/apps/go-mod-validator/action.yaml @@ -28,6 +28,17 @@ inputs: smartcontractkit/chainlink:main,release/2.30.1 smartcontractkit/chainlink-ccip:develop default: "" + repo-sha-exceptions: + description: | + Each line should contain a repository and a comma separated list of commit SHAs. + Only applies to pseudo-version dependencies (commit-pinned, not tag-based). + SHA matching is prefix-based: a 12-char abbreviated SHA matches a full 40-char SHA with that prefix. + Format: /:,,... + Can include the same repo multiple times to append more SHAs. + Example: + smartcontractkit/chainlink:abcdefabcdef + smartcontractkit/chainlink-ccip:deadbeef1234,cafebabe5678 + default: "" runs: using: "node24" diff --git a/apps/go-mod-validator/dist/index.js b/apps/go-mod-validator/dist/index.js index 603fcc111..69709eeab 100644 --- a/apps/go-mod-validator/dist/index.js +++ b/apps/go-mod-validator/dist/index.js @@ -50323,7 +50323,8 @@ function getInputs() { depPrefix: getRunInputString("depPrefix"), repoBranchExceptions: getRunInputRepoBranchExceptions( "repoBranchExceptions" - ) + ), + repoShaExceptions: getRunInputRepoBranchExceptions("repoShaExceptions") }; logInputs(inputs); return inputs; @@ -50341,6 +50342,11 @@ function logInputs(inputs) { Array.from(inputs.repoBranchExceptions.entries()) )}` ); + core4.info( + ` repoShaExceptions: ${JSON.stringify( + Array.from(inputs.repoShaExceptions.entries()) + )}` + ); } var runInputsConfiguration = { githubToken: { @@ -50362,6 +50368,10 @@ var runInputsConfiguration = { repoBranchExceptions: { parameter: "repo-branch-exceptions", localParameter: "REPO_BRANCH_EXCEPTIONS" + }, + repoShaExceptions: { + parameter: "repo-sha-exceptions", + localParameter: "REPO_SHA_EXCEPTIONS" } }; function getRunInputString(input, defaultValue = "") { @@ -50406,6 +50416,12 @@ function getRunInputRepoBranchExceptions(input) { } return repoBranchMap; } +function shaMatches(depSha, exceptionSha) { + if (!depSha || !exceptionSha) return false; + const dep = depSha.toLowerCase(); + const exc = exceptionSha.toLowerCase(); + return dep.length <= exc.length ? exc.startsWith(dep) : dep.startsWith(exc); +} function parseRepoBranchLine(line) { const [repo, ...rest] = line.split(":").map((s) => s.trim()); if (!repo) { @@ -50573,6 +50589,21 @@ async function run() { } async function validateDependency(octokit, dep, inputs) { core5.info(`Validating dependency: ${dep.owner}/${dep.repo}@${dep.version}`); + if ("commitSha" in dep) { + const shaExceptions = inputs.repoShaExceptions.get(`${dep.owner}/${dep.repo}`) || []; + core5.debug( + `SHA exceptions for ${dep.owner}/${dep.repo}: ${shaExceptions.join(", ")}` + ); + const matchedSha = shaExceptions.find( + (exc) => shaMatches(dep.commitSha, exc) + ); + if (matchedSha) { + core5.info( + `[${dep.goModFilePath}] dependency ${dep.name} allowed by SHA exception (matched: ${matchedSha})` + ); + return null; + } + } const defaultBranch = await getDefaultBranch(octokit, dep); const exceptions = inputs.repoBranchExceptions.get(`${dep.owner}/${dep.repo}`) || []; core5.debug(`Default branch for ${dep.owner}/${dep.repo} is ${defaultBranch}`); diff --git a/apps/go-mod-validator/scripts/payload.json b/apps/go-mod-validator/scripts/payload.json index db2740c8d..50e886f8c 100644 --- a/apps/go-mod-validator/scripts/payload.json +++ b/apps/go-mod-validator/scripts/payload.json @@ -1,13 +1,7 @@ { - "//pull_request": { - "base": { "sha": "1f5fbda7ae76d5494b9864db90a8bfe7183db5fb" }, - "head": { "sha": "04c256fe307ced678afbebb5cf87abab747ccb8b" }, - "number": 773 - }, - "issue": { - "number": -1, - "pull_request": { - "number": -1 - } + "pull_request": { + "base": { "sha": "5b92e2511d16d1aaafc73ce05c51e6ece0f0363f" }, + "head": { "sha": "ee78d55de54240967d8dc405ef6d42d6b4996e6b" }, + "number": 22528 } } diff --git a/apps/go-mod-validator/scripts/test.sh b/apps/go-mod-validator/scripts/test.sh index 98e3cdafb..026656968 100755 --- a/apps/go-mod-validator/scripts/test.sh +++ b/apps/go-mod-validator/scripts/test.sh @@ -4,12 +4,13 @@ abspath() { cd "$1" && pwd; } REPO_ROOT="$(git rev-parse --show-toplevel)" +echo "Repo Root: $REPO_ROOT" pnpm nx build go-mod-validator export GITHUB_TOKEN=$(gh auth token) export GITHUB_ACTOR=$(gh api user --jq .login) -export GITHUB_REPOSITORY="smartcontractkit/billing-platform-service" +export GITHUB_REPOSITORY="smartcontractkit/chainlink" export GITHUB_EVENT_NAME="push" export GITHUB_EVENT_PATH="apps/go-mod-validator/scripts/payload.json" @@ -18,8 +19,11 @@ tmp_file=$(mktemp) export GITHUB_STEP_SUMMARY="$tmp_file" export INPUT_GITHUB_TOKEN="$GITHUB_TOKEN" -export INPUT_GO_MOD_DIR="${INPUT_GO_MOD_DIR:-$(abspath "${REPO_ROOT}/../billing-platform-service")}" +export INPUT_GO_MOD_DIR="${INPUT_GO_MOD_DIR:-$(realpath "${REPO_ROOT}/../chainlink/")}" +echo INPUT_GO_MOD_DIR="$INPUT_GO_MOD_DIR" export INPUT_DEP_PREFIX="github.com/smartcontractkit" +# export INPUT_REPO_BRANCH_EXCEPTIONS="smartcontractkit/chainlink-aptos:ogt/cherry-pick-revert-txm-duration-config-types-into-2-47-0-branch" +export INPUT_REPO_SHA_EXCEPTIONS="smartcontractkit/chainlink-aptos:c2a8d09e5b46c1c7815700f645a50caf8a466bda" export CL_LOCAL_DEBUG="true" node apps/go-mod-validator/dist/index.js diff --git a/apps/go-mod-validator/src/go-mod-validator.ts b/apps/go-mod-validator/src/go-mod-validator.ts index 159c265fa..4e3be1d19 100644 --- a/apps/go-mod-validator/src/go-mod-validator.ts +++ b/apps/go-mod-validator/src/go-mod-validator.ts @@ -5,7 +5,7 @@ import { throttling } from "@octokit/plugin-throttling"; import { getDeps, BaseGoModule, lineForDependencyPathFinder } from "./deps"; import { getChangedGoModFiles } from "./diff"; import { getDefaultBranch, isGoModReferencingBranch } from "./github"; -import { getInputs } from "./run-inputs"; +import { getInputs, shaMatches } from "./run-inputs"; import { FIXING_ERRORS } from "./strings"; import type { Octokit } from "./github"; @@ -182,6 +182,25 @@ async function validateDependency( ): Promise { core.info(`Validating dependency: ${dep.owner}/${dep.repo}@${dep.version}`); + // SHA exception check: only for pseudo-version (commit-pinned) deps. + // Short-circuits before any GitHub API call. + if ("commitSha" in dep) { + const shaExceptions = + inputs.repoShaExceptions.get(`${dep.owner}/${dep.repo}`) || []; + core.debug( + `SHA exceptions for ${dep.owner}/${dep.repo}: ${shaExceptions.join(", ")}`, + ); + const matchedSha = shaExceptions.find((exc) => + shaMatches(dep.commitSha, exc), + ); + if (matchedSha) { + core.info( + `[${dep.goModFilePath}] dependency ${dep.name} allowed by SHA exception (matched: ${matchedSha})`, + ); + return null; + } + } + const defaultBranch = await getDefaultBranch(octokit, dep); const exceptions = inputs.repoBranchExceptions.get(`${dep.owner}/${dep.repo}`) || []; diff --git a/apps/go-mod-validator/src/run-inputs.ts b/apps/go-mod-validator/src/run-inputs.ts index ceacdc8d3..6cab81f1e 100644 --- a/apps/go-mod-validator/src/run-inputs.ts +++ b/apps/go-mod-validator/src/run-inputs.ts @@ -8,6 +8,7 @@ export interface RunInputs { goModDir: string; depPrefix: string; repoBranchExceptions: Map; + repoShaExceptions: Map; } export function getInputs(): RunInputs { @@ -23,6 +24,7 @@ export function getInputs(): RunInputs { repoBranchExceptions: getRunInputRepoBranchExceptions( "repoBranchExceptions", ), + repoShaExceptions: getRunInputRepoBranchExceptions("repoShaExceptions"), }; logInputs(inputs); @@ -42,6 +44,11 @@ function logInputs(inputs: RunInputs) { Array.from(inputs.repoBranchExceptions.entries()), )}`, ); + core.info( + ` repoShaExceptions: ${JSON.stringify( + Array.from(inputs.repoShaExceptions.entries()), + )}`, + ); } interface RunInputConfiguration { @@ -80,6 +87,10 @@ const runInputsConfiguration: { parameter: "repo-branch-exceptions", localParameter: "REPO_BRANCH_EXCEPTIONS", }, + repoShaExceptions: { + parameter: "repo-sha-exceptions", + localParameter: "REPO_SHA_EXCEPTIONS", + }, }; function getRunInputString(input: keyof RunInputs, defaultValue = "") { @@ -140,6 +151,20 @@ export function getRunInputRepoBranchExceptions( return repoBranchMap; } +/** + * Note: Exported for testing purposes. + * + * Returns true if depSha and exceptionSha refer to the same commit. + * Prefix-matches to handle abbreviated (12-char) vs full (40-char) SHAs. + * Go pseudo-versions always carry 12-char abbreviated SHAs. + */ +export function shaMatches(depSha: string, exceptionSha: string): boolean { + if (!depSha || !exceptionSha) return false; + const dep = depSha.toLowerCase(); + const exc = exceptionSha.toLowerCase(); + return dep.length <= exc.length ? exc.startsWith(dep) : dep.startsWith(exc); +} + function parseRepoBranchLine(line: string): [string, string[]] { const [repo, ...rest] = line.split(":").map((s) => s.trim()); if (!repo) { diff --git a/apps/go-mod-validator/test/run-input.test.ts b/apps/go-mod-validator/test/run-input.test.ts index 85d1beb7b..21240a9b5 100644 --- a/apps/go-mod-validator/test/run-input.test.ts +++ b/apps/go-mod-validator/test/run-input.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { getRunInputRepoBranchExceptions } from "../src/run-inputs"; +import { getRunInputRepoBranchExceptions, shaMatches } from "../src/run-inputs"; import * as core from "@actions/core"; vi.mock("@actions/core", () => ({ @@ -162,3 +162,38 @@ smartcontractkit/.github: develop,feature/test`; }).toThrow("No branch in line: smartcontractkit/.github main"); }); }); + +describe("shaMatches", () => { + it("returns true for exact 12-char match", () => { + expect(shaMatches("abcdefabcdef", "abcdefabcdef")).toBe(true); + }); + + it("returns false for exact 12-char mismatch", () => { + expect(shaMatches("abcdefabcdef", "111111111111")).toBe(false); + }); + + it("returns true when 40-char exception starts with the 12-char dep SHA", () => { + expect( + shaMatches("abcdefabcdef", "abcdefabcdef1234567890abcdefabcdef12345678"), + ).toBe(true); + }); + + it("returns false when 40-char exception does not start with the dep SHA", () => { + expect( + shaMatches("abcdefabcdef", "111111111111abcdefabcdef1234567890abcdef"), + ).toBe(false); + }); + + it("is case-insensitive", () => { + expect(shaMatches("abcdefabcdef", "ABCDEFABCDEF")).toBe(true); + expect(shaMatches("ABCDEFABCDEF", "abcdefabcdef")).toBe(true); + }); + + it("returns false when depSha is empty", () => { + expect(shaMatches("", "abcdefabcdef")).toBe(false); + }); + + it("returns false when exceptionSha is empty", () => { + expect(shaMatches("abcdefabcdef", "")).toBe(false); + }); +});