Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/few-insects-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion .github/actions/shared-node-cache/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/chromatic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/gh-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
28 changes: 15 additions & 13 deletions .github/workflows/node-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ 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
Expand All @@ -49,11 +49,11 @@ jobs:
# 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 }}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -101,22 +103,22 @@ 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 }}
files: ".eslintrc.js,package.json,pnpm-lock.yaml,.eslintignore"

# 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
Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
227 changes: 227 additions & 0 deletions utils/update-pinned-actions.js
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we upgrade this to be a .ts script using swc? We do that in other scripts in this dir and it at least gives us some type safety.

Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* 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@<sha> # <tag>`) — updates stale SHAs
* 2. Unpinned (`uses: owner/repo@<tag>`) — replaces with `@<sha> # <tag>`
Comment on lines +2 to +5
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you say "stale", does this mean "not latest" or something else? ie. Is this an upgrade script or just one that transforms our workflow yaml file to all have SHA-pinned actions references?

*
* Usage: node utils/update-pinned-actions.js
*/
import {execSync} from "node:child_process";
import fs from "node:fs";

import fg from "fast-glob";

// Matches already-pinned: `uses: owner/repo@<sha> # <tag>`
// - Lookbehind ensures no `#` before `uses:` on the same line (skips YAML comments)
// - Supports optional quotes: `uses: "owner/repo@<sha>" # <tag>`
// - Uses [^\S\n]+ instead of \s+ to prevent matching across lines
// Groups: action(1), sha(2), quote(3), ref(4)
const PINNED_RE =
/(?<=^[^#\n]*uses:\s+"?)([^@\s"]+)@([a-f0-9]{40})("?)[^\S\n]+#[^\S\n]*(\S+)/gm;

// Matches pinned-without-tag: `uses: owner/repo@<sha>` (no `# <tag>` comment)
// Used only for collecting action names, not for updates.
// Groups: action(1)
const PINNED_NO_TAG_RE =
/(?<=^[^#\n]*uses:\s+"?)([^\s@"]+\/[^\s@"]+)@[a-f0-9]{40}"?\s*$/gm;

// Matches unpinned: `uses: owner/repo@<tag>` (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@<tag>"`
// - Action name uses [^\s@"] to avoid capturing quotes
// Groups: action(1), unused(2), ref(3), quote(4)
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, ref) => {
// 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) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if we fall back to a branch ref, the might want to warn. I guess we will still have the changes in the repo to review through normal git workflows... but it feels worth warning about.

return branchOutput.split(/\s+/)[0];
}

return null;
};

// -- main -------------------------------------------------------------------

const files = fg.sync([
".github/workflows/*.yml",
".github/workflows/*.yaml",
".github/actions/**/*.yml",
".github/actions/**/*.yaml",
"actions/**/action.yml",
"actions/**/action.yaml",
]);

// Collect unique action+ref pairs across all files

const seen = new Map(); // key: "action@ref" → resolved SHA (filled later)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although not entirely necessary, I find moving the contents of the script body into a main function useful so that it's easy to scan the script to see what all does. This script also feels like it could use some functions extracted for the steps it takes.


const allRepos = new Set(); // all unique owner/repo names (for listing)

for (const file of files) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These loops all feel like they'd be handy to have in functions so the steps of this script are easier to digest.

const content = fs.readFileSync(file, "utf-8");
let m;

// Collect already-pinned refs
// Groups: action(1), sha(2), quote(3), ref(4)
PINNED_RE.lastIndex = 0;
while ((m = PINNED_RE.exec(content)) !== null) {
const [, action, , , ref] = m;
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;
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;
seen.set(`${action}@${ref}`, null);
allRepos.add(action.split("/").slice(0, 2).join("/"));
}
}

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.log("Allowed actions:\n");
for (const repo of uniqueRepos) {
console.log(`${repo}@*,`);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Can we indent this repo listing and make it a "bulleted" list?

Suggested change
console.log(`${repo}@*,`);
console.log(` - ${repo}@*,`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternately, we could use console.group() and console.groupEnd(). 🤷‍♂️

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, since the workflow yml files reference actions as repo/name, would we be more clear with this listing if we used:

console.log(`${repo}/*`);

}
console.log("");

console.log(`Found ${seen.size} unique action reference(s). Resolving…\n`);

// Resolve each unique action+ref
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) {
console.log(` ⚠ Error resolving ${action}@${ref}: ${err.message}`);
failures++;
}
}

console.log("");

// Update files in-place
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
// Groups: action(1), sha(2), quote(3), ref(4)
PINNED_RE.lastIndex = 0;
content = content.replace(
PINNED_RE,
(match, action, oldSha, quote, ref) => {
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)
// Groups: action(1), unused(2), ref(3), quote(4)
UNPINNED_RE.lastIndex = 0;
content = content.replace(
UNPINNED_RE,
(match, action, _unused, ref, quote) => {
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++;
}
}

// 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);
}