diff --git a/.changeset/few-insects-invite.md b/.changeset/few-insects-invite.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/few-insects-invite.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/actions/shared-node-cache/action.yml b/.github/actions/shared-node-cache/action.yml index 2493ac05d3e..ffb2f9ec734 100644 --- a/.github/actions/shared-node-cache/action.yml +++ b/.github/actions/shared-node-cache/action.yml @@ -12,13 +12,13 @@ runs: using: "composite" steps: # NOTE: The pnpm/action-setup must come before action/setup-node! - - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 name: Install pnpm with: run_install: false - name: Use Node.js ${{ inputs.node-version }} - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: ${{ inputs.node-version }} cache: pnpm diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index fb45df3269e..2d158524453 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -38,7 +38,7 @@ jobs: node-version: ${{ matrix.node-version }} - name: Publish to Chromatic for visual testing - uses: chromaui/action@f1f9e3277eb1eaa8cba4c6bcebc9809291ee29ea # v15.0.0 + uses: chromaui/action@f191a0224b10e1a38b2091cefb7b7a2337009116 # v16.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} projectToken: ${{ secrets.CHROMATIC_APP_CODE }} diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 7ab49e5c8dd..fa8573f7061 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -56,6 +56,6 @@ jobs: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 - run: echo ${{ steps.deployment.outputs.page_url }} diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index ba5ba6c4bcb..345d35220eb 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -36,24 +36,24 @@ jobs: with: fetch-depth: 0 - - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5 name: Install pnpm with: run_install: false - name: Force Node version - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: ${{ matrix.node-version }} # Note that we don't specify 'cache: pnpm' here because we # don't install node_modules in this workflow/job! - name: Get changed files - uses: Khan/actions@1662367b281368328845c72f77028f71d768b588 # get-changed-files-v2 + uses: Khan/actions@30cadf1146cce2e3cdd2510dc8378063e9b08b58 # get-changed-files-v3 id: changed - name: Filter out files that don't need a changeset - uses: Khan/actions@70e6afa0077187462838d024a1773adf217fb2b8 # filter-files-v2 + uses: Khan/actions@069171ef7dea82ab80a8e499c4217d9b812f6af4 # filter-files-v3 id: match with: changed-files: ${{ steps.changed.outputs.files }} @@ -62,14 +62,16 @@ jobs: matchAllGlobs: true # Default is to match any of the globs, which ends up matching all files conjunctive: true # Only match files that match all of the above - - uses: webfactory/ssh-agent@a6f90b1f127823b31d4d4a8d96047790581349bd # v0.9.1 + - uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0 with: ssh-private-key: ${{ secrets.KHAN_ACTIONS_BOT_SSH_PRIVATE_KEY }} - name: Verify changeset entries - uses: Khan/actions@2faf7c5ee0179da7639a01731d7b9997695bb723 # check-for-changeset-v1 + uses: Khan/actions@4c5c5cbcfcdb1c059c9601b41bb686e75cc52544 # check-for-changeset-v2 with: - changed_files: ${{ steps.match.outputs.filtered }} + exclude: .github/,.storybook/ + exclude_extensions: .test.ts, .test.tsx, .stories.ts, .stories.tsx, .mdx + exclude_globs: "**/__tests__/*, **/__docs__/*" lint: name: Lint, Typecheck, Format, and Test @@ -88,7 +90,7 @@ jobs: node-version: ${{ matrix.node-version }} - name: Get All Changed Files - uses: Khan/actions@1662367b281368328845c72f77028f71d768b588 # get-changed-files-v2 + uses: Khan/actions@30cadf1146cce2e3cdd2510dc8378063e9b08b58 # get-changed-files-v3 id: changed - name: Check formatting @@ -101,14 +103,14 @@ jobs: - id: js-files name: Find .js(x)/.ts(x) changed files - uses: Khan/actions@70e6afa0077187462838d024a1773adf217fb2b8 # filter-files-v2 + uses: Khan/actions@069171ef7dea82ab80a8e499c4217d9b812f6af4 # filter-files-v3 with: changed-files: ${{ steps.changed.outputs.files }} extensions: ".js,.jsx,.ts,.tsx" files: "pnpm-lock.yaml,tsconfig-build.json,tsconfig-common.json,tsconfig.json,packages/tsconfig-shared.json" - id: eslint-reset - uses: Khan/actions@70e6afa0077187462838d024a1773adf217fb2b8 # filter-files-v2 + uses: Khan/actions@069171ef7dea82ab80a8e499c4217d9b812f6af4 # filter-files-v3 name: Files that would trigger a full eslint run with: changed-files: ${{ steps.changed.outputs.files }} @@ -116,7 +118,7 @@ jobs: # Linting / type checking - name: Eslint - uses: Khan/actions@faa1972efeb996a27a95ddbd4ce5b555c40cd2e3 # full-or-limited-v0 + uses: Khan/actions@8b8506a789a5c2a4a90bd8da8b6801db6931ee2d # full-or-limited-v0 with: full-trigger: ${{ steps.eslint-reset.outputs.filtered }} full: pnpm lint packages @@ -138,14 +140,14 @@ jobs: # Run tests for our target matrix - id: jest-reset - uses: Khan/actions@70e6afa0077187462838d024a1773adf217fb2b8 # filter-files-v2 + uses: Khan/actions@069171ef7dea82ab80a8e499c4217d9b812f6af4 # filter-files-v3 name: Files that would trigger a full jest run with: changed-files: ${{ steps.changed.outputs.files }} files: "jest.config.js,package.json,pnpm-lock.yaml,test.config.js,test.transform.js" - name: Jest - uses: Khan/actions@faa1972efeb996a27a95ddbd4ce5b555c40cd2e3 # full-or-limited-v0 + uses: Khan/actions@8b8506a789a5c2a4a90bd8da8b6801db6931ee2d # full-or-limited-v0 with: full-trigger: ${{ steps.jest-reset.outputs.filtered }} full: pnpm jest @@ -224,7 +226,7 @@ jobs: # in place for the next job; in other words, it leaves the repo on a # base branch. - name: Check Builds - uses: preactjs/compressed-size-action@8518045ed95e94e971b83333085e1cb99aa18aa8 # v2 + uses: preactjs/compressed-size-action@66325aad6443cb7cf89c4bfcd414aea2367cda94 # v2 with: # We only care about the ES module size, really: pattern: "**/dist/es/*.js" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bb6e9f0951d..9a8db032ed7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -90,7 +90,7 @@ jobs: - name: Create Release Pull Request or Publish to npm id: changesets - uses: changesets/action@c48e67d110a68bc90ccf1098e9646092baacaa87 # v1.6.0 + uses: changesets/action@6a0a831ff30acef54f2c6aa1cbbc1096b066edaf # v1.7.0 with: publish: pnpm publish:ci env: diff --git a/utils/__tests__/update-pinned-actions.test.ts b/utils/__tests__/update-pinned-actions.test.ts new file mode 100644 index 00000000000..d0d7529340b --- /dev/null +++ b/utils/__tests__/update-pinned-actions.test.ts @@ -0,0 +1,214 @@ +/** + * @jest-environment node + */ +import fs from "node:fs"; + +import {beforeEach, describe, expect, it, jest} from "@jest/globals"; + +import {collectActionRefs} from "../update-pinned-actions"; + +jest.mock("node:fs"); + +// A valid 40-character hex SHA for use in tests +const SHA = "abcdef0123456789abcdef0123456789abcdef01"; + +describe("collectActionRefs", () => { + let mockReadFileSync: jest.SpiedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockReadFileSync = jest.spyOn(fs, "readFileSync"); + }); + + it("returns empty maps for an empty file list", () => { + const {seen, allRepos} = collectActionRefs([]); + + expect(seen).toMatchInlineSnapshot(`Map {}`); + expect(allRepos).toMatchInlineSnapshot(`Set {}`); + }); + + describe("already-pinned refs (uses: owner/repo@ # )", () => { + it("adds action@ref to seen with a null SHA", () => { + mockReadFileSync.mockReturnValue( + ` uses: actions/checkout@${SHA} # v4.1.0` as any, + ); + + const {seen} = collectActionRefs(["workflow.yml"]); + + expect(seen).toMatchInlineSnapshot(` +Map { + "actions/checkout@v4.1.0" => null, +} +`); + }); + + it("adds owner/repo to allRepos", () => { + mockReadFileSync.mockReturnValue( + ` uses: actions/checkout@${SHA} # v4.1.0` as any, + ); + + const {allRepos} = collectActionRefs(["workflow.yml"]); + + expect(allRepos).toMatchInlineSnapshot(` +Set { + "actions/checkout", +} +`); + }); + + it("strips subpath from action when adding to allRepos", () => { + mockReadFileSync.mockReturnValue( + ` uses: actions/cache/restore@${SHA} # v3` as any, + ); + + const {allRepos} = collectActionRefs(["workflow.yml"]); + + expect(allRepos).toMatchInlineSnapshot(` +Set { + "actions/cache", +} +`); + }); + + it("skips lines that are YAML comments", () => { + mockReadFileSync.mockReturnValue( + ` # uses: actions/checkout@${SHA} # v4.1.0` as any, + ); + + const {seen, allRepos} = collectActionRefs(["workflow.yml"]); + + expect(seen).toMatchInlineSnapshot(`Map {}`); + expect(allRepos).toMatchInlineSnapshot(`Set {}`); + }); + + it("handles quoted uses values", () => { + mockReadFileSync.mockReturnValue( + ` uses: "actions/checkout@${SHA}" # v4.1.0` as any, + ); + + const {seen} = collectActionRefs(["workflow.yml"]); + + expect(seen).toMatchInlineSnapshot(` +Map { + "actions/checkout@v4.1.0" => null, +} +`); + }); + }); + + describe("pinned-without-tag refs (uses: owner/repo@, no comment)", () => { + it("adds owner/repo to allRepos but not to seen", () => { + // Trailing newline is required: UNPINNED_RE's negative lookahead + // `(?![a-f0-9]{40}(?:\s|"))` only excludes a SHA when followed by + // whitespace or a quote. Without `\n`, the SHA is matched as a tag. + mockReadFileSync.mockReturnValue( + ` uses: actions/checkout@${SHA}\n` as any, + ); + + const {seen, allRepos} = collectActionRefs(["workflow.yml"]); + + expect(seen).toMatchInlineSnapshot(`Map {}`); + expect(allRepos).toMatchInlineSnapshot(` +Set { + "actions/checkout", +} +`); + }); + }); + + describe("unpinned refs (uses: owner/repo@)", () => { + it("adds action@ref to seen with a null SHA", () => { + mockReadFileSync.mockReturnValue( + ` uses: actions/checkout@v4.1.0` as any, + ); + + const {seen} = collectActionRefs(["workflow.yml"]); + + expect(seen).toMatchInlineSnapshot(` +Map { + "actions/checkout@v4.1.0" => null, +} +`); + }); + + it("adds owner/repo to allRepos", () => { + mockReadFileSync.mockReturnValue( + ` uses: actions/checkout@v4.1.0` as any, + ); + + const {allRepos} = collectActionRefs(["workflow.yml"]); + + expect(allRepos).toMatchInlineSnapshot(` +Set { + "actions/checkout", +} +`); + }); + }); + + describe("deduplication", () => { + it("deduplicates the same action@ref across multiple files", () => { + mockReadFileSync.mockReturnValue( + ` uses: actions/checkout@v4.1.0` as any, + ); + + const {seen} = collectActionRefs([ + "workflow1.yml", + "workflow2.yml", + ]); + + expect(seen).toMatchInlineSnapshot(` +Map { + "actions/checkout@v4.1.0" => null, +} +`); + }); + + it("deduplicates the same repo across different ref patterns", () => { + mockReadFileSync + .mockReturnValueOnce( + ` uses: actions/checkout@${SHA} # v4.1.0` as any, + ) + .mockReturnValueOnce( + ` uses: actions/checkout@v4.1.0` as any, + ); + + const {allRepos} = collectActionRefs([ + "workflow1.yml", + "workflow2.yml", + ]); + + expect(allRepos).toMatchInlineSnapshot(` +Set { + "actions/checkout", +} +`); + }); + }); + + describe("multiple actions in one file", () => { + it("collects all action refs", () => { + mockReadFileSync.mockReturnValue( + [ + ` uses: actions/checkout@${SHA} # v4.1.0`, + ` uses: actions/setup-node@v4`, + ].join("\n") as any, + ); + + const {seen, allRepos} = collectActionRefs(["workflow.yml"]); + + expect(seen).toMatchInlineSnapshot(` +Map { + "actions/checkout@v4.1.0" => null, + "actions/setup-node@v4" => null, +} +`); + expect(allRepos).toMatchInlineSnapshot(` +Set { + "actions/checkout", + "actions/setup-node", +} +`); + }); + }); +}); diff --git a/utils/update-pinned-actions.ts b/utils/update-pinned-actions.ts new file mode 100644 index 00000000000..1cf16c53f0b --- /dev/null +++ b/utils/update-pinned-actions.ts @@ -0,0 +1,280 @@ +#!/usr/bin/env -S node -r @swc-node/register +/** + * Scan all workflow and action YAML files for GitHub Action references and + * ensure they are pinned to commit SHAs. Handles two cases: + * 1. Already pinned (`uses: owner/repo@ # `) — updates stale SHAs + * 2. Unpinned (`uses: owner/repo@`) — replaces with `@ # ` + * + * Usage: node utils/update-pinned-actions.ts + */ +import {execSync} from "node:child_process"; +import fs from "node:fs"; + +import fg from "fast-glob"; + +// Matches already-pinned: `uses: owner/repo@ # ` +// - Lookbehind ensures no `#` before `uses:` on the same line (skips YAML comments) +// - Supports optional quotes: `uses: "owner/repo@" # ` +// - Uses [^\S\n]+ instead of \s+ to prevent matching across lines +// Named groups: action, sha, quote, ref +const PINNED_RE = + /(?<=^[^#\n]*uses:\s+"?)(?[^@\s"]+)@(?[a-f0-9]{40})(?"?)[^\S\n]+#[^\S\n]*(?\S+)/gm; + +// Matches pinned-without-tag: `uses: owner/repo@` (no `# ` comment) +// Used only for collecting action names, not for updates. +// Named groups: action +const PINNED_NO_TAG_RE = + /(?<=^[^#\n]*uses:\s+"?)(?[^\s@"]+\/[^\s@"]+)@[a-f0-9]{40}"?\s*$/gm; + +// Matches unpinned: `uses: owner/repo@` (where tag is NOT a 40-char hex SHA) +// - Excludes local actions (starting with ./) +// - Lookbehind ensures no `#` before `uses:` on the same line (skips YAML comments) +// - Supports optional quotes: `uses: "owner/repo@"` +// - Action name uses [^\s@"] to avoid capturing quotes +// Named groups: action, ref, quote +const UNPINNED_RE = + /(?<=^[^#\n]*uses:\s+"?)(?[^\s@"]+\/[^\s@"]+)@(?!(?:[a-f0-9]{40})(?:\s|"))(?[^\s"]+)(?"?)/gm; + +/** + * Resolve a tag or branch to its commit SHA via git ls-remote. + * For annotated tags the dereferenced (^{}) commit SHA is returned. + */ +const resolveRef = (action: string, ref: string): string | null => { + // Extract just owner/repo (ignore sub-paths like /restore, /save) + const repo = action.split("/").slice(0, 2).join("/"); + const url = `https://github.com/${repo}.git`; + + // Try tags first (covers both lightweight and annotated) + const tagOutput = execSync(`git ls-remote --tags ${url} ${ref} ${ref}^{}`, { + encoding: "utf-8", + }).trim(); + + if (tagOutput) { + const lines = tagOutput.split("\n"); + // If there's a ^{} line it's an annotated tag — use the deref SHA + const deref = lines.find((l) => l.includes("^{}")); + if (deref) { + return deref.split(/\s+/)[0]; + } + return lines[0].split(/\s+/)[0]; + } + + // Fall back to branches + const branchOutput = execSync(`git ls-remote --heads ${url} ${ref}`, { + encoding: "utf-8", + }).trim(); + + if (branchOutput) { + console.warn( + ` ⚠ ${action}@${ref} resolved via branch — consider pinning to a tag instead`, + ); + return branchOutput.split(/\s+/)[0]; + } + + return null; +}; + +/** + * Scan all YAML files and collect unique action+ref pairs to resolve, + * plus all unique repo names (for the allowed-actions listing). + */ +export const collectActionRefs = ( + files: string[], +): { + seen: Map; + allRepos: Set; +} => { + const seen = new Map(); // key: "action@ref" → resolved SHA (filled later) + const allRepos = new Set(); // all unique owner/repo names (for listing) + + for (const file of files) { + const content = fs.readFileSync(file, "utf-8"); + let m: RegExpExecArray | null; + + // Collect already-pinned refs + PINNED_RE.lastIndex = 0; + while ((m = PINNED_RE.exec(content)) !== null) { + const {action, ref} = m.groups!; + seen.set(`${action}@${ref}`, null); + allRepos.add(action.split("/").slice(0, 2).join("/")); + } + + // Collect pinned-without-tag refs (for listing only) + PINNED_NO_TAG_RE.lastIndex = 0; + while ((m = PINNED_NO_TAG_RE.exec(content)) !== null) { + const {action} = m.groups!; + allRepos.add(action.split("/").slice(0, 2).join("/")); + } + + // Collect unpinned refs (tag/branch directly after @) + UNPINNED_RE.lastIndex = 0; + while ((m = UNPINNED_RE.exec(content)) !== null) { + const {action, ref} = m.groups!; + seen.set(`${action}@${ref}`, null); + allRepos.add(action.split("/").slice(0, 2).join("/")); + } + } + + return {seen, allRepos}; +}; + +/** + * Resolve all action+ref pairs in `seen` to their commit SHAs. + * Returns the number of failures. + */ +const resolveAllRefs = (seen: Map): number => { + let failures = 0; + for (const key of seen.keys()) { + const [action, ref] = key.split("@"); + console.log(` Resolving ${action} @ ${ref}`); + try { + const sha = resolveRef(action, ref); + if (!sha) { + console.log( + ` ⚠ Could not resolve ref "${ref}" for ${action}`, + ); + failures++; + } else { + seen.set(key, sha); + console.log(` → ${sha}`); + } + } catch (err: any) { + console.log( + ` ⚠ Error resolving ${action}@${ref}: ${err.message}`, + ); + failures++; + } + } + return failures; +}; + +/** + * Update all YAML files in-place, replacing stale/unpinned refs with resolved SHAs. + * Returns counts of updated files, updated refs, and already-current refs. + */ +const updateFiles = ( + files: string[], + seen: Map, +): {updatedFiles: number; updatedRefs: number; alreadyCurrent: number} => { + let updatedFiles = 0; + let updatedRefs = 0; + let alreadyCurrent = 0; + + for (const file of files) { + let content = fs.readFileSync(file, "utf-8"); + let fileChanged = false; + + // Update already-pinned refs with stale SHAs + PINNED_RE.lastIndex = 0; + content = content.replace(PINNED_RE, (match, ...args) => { + const { + action, + sha: oldSha, + quote, + ref, + } = args.at(-1) as { + action: string; + sha: string; + quote: string; + ref: string; + }; + const newSha = seen.get(`${action}@${ref}`); + if (!newSha || newSha === oldSha) { + if (newSha === oldSha) { + alreadyCurrent++; + } + return match; + } + console.log(` ${file}: ${action}@${ref}`); + console.log(` ${oldSha} → ${newSha}`); + fileChanged = true; + updatedRefs++; + return `${action}@${newSha}${quote} # ${ref}`; + }); + + // Pin unpinned refs (tag/branch → sha # tag) + UNPINNED_RE.lastIndex = 0; + content = content.replace(UNPINNED_RE, (match, ...args) => { + const {action, ref, quote} = args.at(-1) as { + action: string; + ref: string; + quote: string; + }; + const newSha = seen.get(`${action}@${ref}`); + if (!newSha) { + return match; + } + console.log(` ${file}: ${action}@${ref} (unpinned)`); + console.log(` → ${newSha} # ${ref}`); + fileChanged = true; + updatedRefs++; + return `${action}@${newSha}${quote} # ${ref}`; + }); + + if (fileChanged) { + fs.writeFileSync(file, content); + updatedFiles++; + } + } + + return {updatedFiles, updatedRefs, alreadyCurrent}; +}; + +const main = () => { + const files = fg.sync([ + ".github/workflows/*.yml", + ".github/workflows/*.yaml", + ".github/actions/**/*.yml", + ".github/actions/**/*.yaml", + "actions/**/action.yml", + "actions/**/action.yaml", + ]); + + const {seen, allRepos} = collectActionRefs(files); + + if (seen.size === 0) { + console.log("No action references found."); + process.exit(0); + } + + // Print unique non-actions/* repos in alphabetical order + const uniqueRepos = [...allRepos] + .filter((repo) => !repo.startsWith("actions/")) + .sort(); + console.group("Allowed actions:"); + for (const repo of uniqueRepos) { + console.log(`${repo}@*,`); + } + console.groupEnd(); + console.log(""); + + console.log(`Found ${seen.size} unique action reference(s). Resolving…\n`); + const failures = resolveAllRefs(seen); + console.log(""); + + const {updatedFiles, updatedRefs, alreadyCurrent} = updateFiles( + files, + seen, + ); + + // Summary + console.log(""); + if (updatedRefs > 0) { + console.log( + `🏁 Updated ${updatedRefs} reference(s) across ${updatedFiles} file(s).`, + ); + } else { + console.log("🏁 All pinned actions are already up-to-date."); + } + if (alreadyCurrent > 0) { + console.log(` ${alreadyCurrent} reference(s) already current.`); + } + if (failures > 0) { + console.log(` ⚠ ${failures} reference(s) could not be resolved.`); + process.exit(1); + } +}; + +if (require.main === module) { + main(); +}