From 9d1ba9398f6ee44a604dc497866902afd0be07fc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 06:18:27 +0000 Subject: [PATCH 01/11] feat: add update-actions workflow to auto-pin GitHub Actions Adds a scheduled workflow that scans TypeScript source under src/ for `uses: 'owner/repo@ref'` literals, resolves each action to the latest stable release commit SHA, and opens a PR with the pinned updates. Closes #186 https://claude.ai/code/session_01Avf49PnGcNWmgpViU3uzyE --- .gitattributes | 1 + .github/workflows/update-actions.yml | 91 +++++++++++++++++++++ .gitignore | 1 + .projen/files.json | 1 + .projenrc.ts | 110 +++++++++++++++++++++++++ README.md | 30 +++++++ scripts/update-github-actions.mjs | 117 +++++++++++++++++++++++++++ 7 files changed, 351 insertions(+) create mode 100644 .github/workflows/update-actions.yml create mode 100644 scripts/update-github-actions.mjs diff --git a/.gitattributes b/.gitattributes index f8f1829..eb33481 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,6 +11,7 @@ /.github/workflows/integ.yml linguist-generated /.github/workflows/pull-request-lint.yml linguist-generated /.github/workflows/release.yml linguist-generated +/.github/workflows/update-actions.yml linguist-generated /.github/workflows/upgrade-main.yml linguist-generated /.gitignore linguist-generated /.gitpod.yml linguist-generated diff --git a/.github/workflows/update-actions.yml b/.github/workflows/update-actions.yml new file mode 100644 index 0000000..9e9a705 --- /dev/null +++ b/.github/workflows/update-actions.yml @@ -0,0 +1,91 @@ +# ~~ Generated by projen. To modify, edit .projenrc.ts and run "npx projen". + +name: update-actions +on: + workflow_dispatch: {} + schedule: + - cron: 0 6 * * 1 +jobs: + upgrade: + name: Upgrade GitHub Actions + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + patch_created: ${{ steps.create_patch.outputs.patch_created }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Install dependencies + run: npm ci + - name: Pin actions to latest release SHAs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/update-github-actions.mjs + - name: Regenerate project + run: npx projen build + - name: Find mutations + id: create_patch + run: |- + git add . + git diff --staged --patch --exit-code > repo.patch || echo "patch_created=true" >> $GITHUB_OUTPUT + shell: bash + - name: Upload patch + if: steps.create_patch.outputs.patch_created + uses: actions/upload-artifact@v7 + with: + name: repo.patch + path: repo.patch + overwrite: true + pr: + name: Create Pull Request + needs: upgrade + runs-on: ubuntu-latest + permissions: + contents: read + if: ${{ needs.upgrade.outputs.patch_created }} + steps: + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.PROJEN_APP_ID }} + private-key: ${{ secrets.PROJEN_APP_PRIVATE_KEY }} + - name: Checkout + uses: actions/checkout@v6 + - name: Download patch + uses: actions/download-artifact@v8 + with: + name: repo.patch + path: ${{ runner.temp }} + - name: Apply patch + run: '[ -s ${{ runner.temp }}/repo.patch ] && git apply ${{ runner.temp }}/repo.patch || echo "Empty patch. Skipping."' + - name: Set git identity + run: |- + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ steps.generate_token.outputs.token }} + commit-message: "chore(deps): pin github actions to latest release SHAs" + branch: github-actions/update-actions + title: "chore(deps): update pinned GitHub Actions" + labels: auto-approve,dependencies,github-actions + body: |- + Pins action references in `src/` to the latest stable release commit SHAs. + + See the job summary of the [workflow run] for a per-action diff. + + [Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + ------ + + *Automatically created by the `update-actions` workflow.* + author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + signoff: true diff --git a/.gitignore b/.gitignore index fe5fe73..7331586 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,5 @@ tsconfig.json !/API.md !/.github/workflows/integ.yml !/.github/workflows/assign-approver.yml +!/.github/workflows/update-actions.yml !/.projenrc.ts diff --git a/.projen/files.json b/.projen/files.json index 12f9ccf..023b79b 100644 --- a/.projen/files.json +++ b/.projen/files.json @@ -9,6 +9,7 @@ ".github/workflows/integ.yml", ".github/workflows/pull-request-lint.yml", ".github/workflows/release.yml", + ".github/workflows/update-actions.yml", ".github/workflows/upgrade-main.yml", ".gitignore", ".gitpod.yml", diff --git a/.projenrc.ts b/.projenrc.ts index eb678b3..56ff9e0 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -135,4 +135,114 @@ new GitHubAssignApprover(project, { defaultApprovers: ['hoegertn', 'Lock128'], }); +// Weekly maintenance: scan TypeScript source for `uses:` action references, +// pin them to the latest stable release's commit SHA, and open a PR with the +// result. Keeps generated pipelines on current, security-patched actions +// despite them living as string literals (invisible to Dependabot/Renovate). +const updateActionsWf = project.github?.addWorkflow('update-actions'); +updateActionsWf?.on({ + workflowDispatch: {}, + schedule: [{ cron: '0 6 * * 1' }], +}); +updateActionsWf?.addJobs({ + upgrade: { + name: 'Upgrade GitHub Actions', + runsOn: ['ubuntu-latest'], + permissions: { contents: JobPermission.READ }, + outputs: { + patch_created: { stepId: 'create_patch', outputName: 'patch_created' }, + }, + steps: [ + { name: 'Checkout', uses: 'actions/checkout@v6' }, + { + name: 'Setup Node', + uses: 'actions/setup-node@v6', + with: { 'node-version': '22' }, + }, + { name: 'Install dependencies', run: 'npm ci' }, + { + name: 'Pin actions to latest release SHAs', + env: { GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }, + run: 'node scripts/update-github-actions.mjs', + }, + { name: 'Regenerate project', run: 'npx projen build' }, + { + name: 'Find mutations', + id: 'create_patch', + shell: 'bash', + run: [ + 'git add .', + 'git diff --staged --patch --exit-code > repo.patch || echo "patch_created=true" >> $GITHUB_OUTPUT', + ].join('\n'), + }, + { + name: 'Upload patch', + if: 'steps.create_patch.outputs.patch_created', + uses: 'actions/upload-artifact@v7', + with: { name: 'repo.patch', path: 'repo.patch', overwrite: true }, + }, + ], + }, + pr: { + name: 'Create Pull Request', + needs: ['upgrade'], + runsOn: ['ubuntu-latest'], + permissions: { contents: JobPermission.READ }, + if: '${{ needs.upgrade.outputs.patch_created }}', + steps: [ + { + name: 'Generate token', + id: 'generate_token', + uses: 'actions/create-github-app-token@v2', + with: { + 'app-id': '${{ secrets.PROJEN_APP_ID }}', + 'private-key': '${{ secrets.PROJEN_APP_PRIVATE_KEY }}', + }, + }, + { name: 'Checkout', uses: 'actions/checkout@v6' }, + { + name: 'Download patch', + uses: 'actions/download-artifact@v8', + with: { name: 'repo.patch', path: '${{ runner.temp }}' }, + }, + { + name: 'Apply patch', + run: '[ -s ${{ runner.temp }}/repo.patch ] && git apply ${{ runner.temp }}/repo.patch || echo "Empty patch. Skipping."', + }, + { + name: 'Set git identity', + run: [ + 'git config user.name "github-actions[bot]"', + 'git config user.email "41898282+github-actions[bot]@users.noreply.github.com"', + ].join('\n'), + }, + { + name: 'Create Pull Request', + uses: 'peter-evans/create-pull-request@v8', + with: { + 'token': '${{ steps.generate_token.outputs.token }}', + 'commit-message': 'chore(deps): pin github actions to latest release SHAs', + 'branch': 'github-actions/update-actions', + 'title': 'chore(deps): update pinned GitHub Actions', + 'labels': 'auto-approve,dependencies,github-actions', + 'body': [ + 'Pins action references in `src/` to the latest stable release commit SHAs.', + '', + 'See the job summary of the [workflow run] for a per-action diff.', + '', + '[Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}', + '', + '------', + '', + '*Automatically created by the `update-actions` workflow.*', + ].join('\n'), + 'author': 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>', + 'committer': 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>', + 'signoff': true, + }, + }, + ], + }, +}); + project.synth(); \ No newline at end of file diff --git a/README.md b/README.md index b94eb1b..c2b7a64 100644 --- a/README.md +++ b/README.md @@ -590,6 +590,36 @@ const deployStep = new AmplifyDeployStep(project, { }); ``` +## Maintenance: keeping generated actions up to date + +`projen-pipelines` embeds GitHub Action references (e.g. `actions/checkout@v6`) as TypeScript +string literals under `src/`. Because they aren't in real workflow YAML, Dependabot and Renovate +cannot see them, and versions drift over time. + +To keep them current, this repository runs a scheduled `update-actions` workflow +(`.github/workflows/update-actions.yml`, weekly on Monday 06:00 UTC; also `workflow_dispatch`): + +1. Scans `src/` for `uses: 'owner/repo@ref'` literals. +2. For each unique action, queries the GitHub Releases API for the latest stable tag + (pre-releases are skipped unless `ALLOW_PRERELEASE=true`). +3. Resolves the tag to a full commit SHA (following annotated tags to the underlying commit). +4. Rewrites the literal in place, recording the tag as a trailing TypeScript comment so + maintainers can see the human-readable version next to the SHA. +5. Runs `npx projen build` to regenerate workflow snapshots and example output. +6. Opens (or updates) a single PR labelled `dependencies,github-actions` with the resolved + changes. A job-summary table lists every bumped action with its old ref, new SHA, and tag. + +The pinning script lives at `scripts/update-github-actions.mjs` and can be run locally +(`GH_TOKEN=$(gh auth token) node scripts/update-github-actions.mjs`) for a dry-run. + +### Reviewing PRs produced by `update-actions` + +* Confirm each bumped action's release notes at `https://github.com///releases`. +* Verify the job summary matches the diff — every change should be an SHA replacement plus + an updated `// v` comment. +* Check that `npx projen build` output (snapshots, `API.md`) is a noise-only diff. +* Merge as a single PR; the `auto-approve` label fast-tracks it through mergify. + ## Current Status Projen-Pipelines is currently in version 0.x, awaiting Projen's 1.0 release. Despite its pre-1.0 status, it's being used in several production environments. diff --git a/scripts/update-github-actions.mjs b/scripts/update-github-actions.mjs new file mode 100644 index 0000000..66ab0a7 --- /dev/null +++ b/scripts/update-github-actions.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +// Scans TypeScript source for `uses: 'owner/repo@ref'` literals, resolves each +// action's latest stable release to a full commit SHA, and rewrites the +// literals in place. The resolved tag is recorded as a trailing TypeScript +// comment so reviewers can see the human-readable version alongside the SHA. +// +// Relies on the `gh` CLI for authenticated GitHub API access (GH_TOKEN env). + +import { execSync } from 'node:child_process'; +import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = process.argv[2] ?? 'src'; +const ALLOW_PRERELEASE = process.env.ALLOW_PRERELEASE === 'true'; + +// Captures lines like: +// uses: 'actions/checkout@v6', +// uses: 'aws-actions/configure-aws-credentials@a1b2c3', // v5.0.0 +// Group 1: leading ` uses: '`, Group 2: owner/repo(/sub), Group 3: ref, +// Group 4: closing `'` + optional comma, Group 5: trailing characters. +const LINE_RE = /^(\s*uses:\s*')([A-Za-z0-9_.\-/]+)@([A-Za-z0-9_.\-]+)('\s*,?)([^\n]*)$/gm; + +function walk(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + const s = statSync(p); + if (s.isDirectory()) out.push(...walk(p)); + else if (p.endsWith('.ts')) out.push(p); + } + return out; +} + +function gh(path) { + const raw = execSync(`gh api ${path}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + return JSON.parse(raw); +} + +function repoRoot(useRef) { + // Sub-path actions like `github/codeql-action/init` must be resolved against + // the root repo. Take only the first two path segments. + const [owner, repo] = useRef.split('/'); + return `${owner}/${repo}`; +} + +function latestStableTag(repo) { + if (ALLOW_PRERELEASE) { + const releases = gh(`/repos/${repo}/releases?per_page=10`); + if (Array.isArray(releases) && releases.length) return releases[0].tag_name; + } else { + try { + return gh(`/repos/${repo}/releases/latest`).tag_name; + } catch { + // Repo may not publish GitHub Releases; fall through to tags. + } + } + const tags = gh(`/repos/${repo}/tags?per_page=1`); + if (!Array.isArray(tags) || tags.length === 0) { + throw new Error(`No releases or tags found for ${repo}`); + } + return tags[0].name; +} + +function resolveSha(repo, tag) { + const ref = gh(`/repos/${repo}/git/ref/tags/${encodeURIComponent(tag)}`); + let object = ref.object; + // Annotated tags point to a tag object; follow through to the commit. + while (object && object.type === 'tag') { + const t = gh(`/repos/${repo}/git/tags/${object.sha}`); + object = t.object; + } + if (!object || !object.sha) throw new Error(`Unable to resolve SHA for ${repo}@${tag}`); + return object.sha; +} + +const files = walk(ROOT); +const seen = new Map(); // repoRoot -> { tag, sha } +const changes = []; // { file, repo, from, to, tag } + +for (const file of files) { + const original = readFileSync(file, 'utf8'); + const updated = original.replace(LINE_RE, (line, pre, repo, ref, post, trailing) => { + const root = repoRoot(repo); + let target = seen.get(root); + if (!target) { + try { + const tag = latestStableTag(root); + const sha = resolveSha(root, tag); + target = { tag, sha }; + seen.set(root, target); + console.error(`resolved ${root} ${tag} -> ${sha}`); + } catch (err) { + console.error(`skip ${root}: ${err.message}`); + seen.set(root, null); + return line; + } + } + if (!target) return line; + if (ref === target.sha) return line; + + changes.push({ file, repo, from: ref, to: target.sha, tag: target.tag }); + const stripped = trailing.replace(/\s*\/\/.*$/, '').trimEnd(); + return `${pre}${repo}@${target.sha}${post}${stripped} // ${target.tag}`.trimEnd(); + }); + if (updated !== original) writeFileSync(file, updated); +} + +const summaryPath = process.env.GITHUB_STEP_SUMMARY; +if (summaryPath && changes.length) { + const lines = ['## Action updates', '', '| Action | From | To (SHA) | Tag |', '| --- | --- | --- | --- |']; + for (const c of changes) { + lines.push(`| \`${c.repo}\` | \`${c.from}\` | \`${c.to}\` | \`${c.tag}\` |`); + } + writeFileSync(summaryPath, lines.join('\n') + '\n', { flag: 'a' }); +} + +console.log(JSON.stringify({ changes, resolved: [...seen.entries()] }, null, 2)); From b7e33054a1c2f1dc64d8826f6d40839a2d0c32bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 07:05:45 +0000 Subject: [PATCH 02/11] refactor: rewrite update-github-actions script in TypeScript Converts the maintenance script from .mjs to .ts so it participates in the project's lint/type-check pipeline. Adds scripts/ to the dev tsconfig includes and to the eslint scope, and invokes the script via ts-node from the workflow. https://claude.ai/code/session_01Avf49PnGcNWmgpViU3uzyE --- .github/workflows/update-actions.yml | 2 +- .projen/tasks.json | 2 +- .projenrc.ts | 8 +- README.md | 5 +- ...b-actions.mjs => update-github-actions.ts} | 80 +++++++++++++------ tsconfig.dev.json | 1 + 6 files changed, 70 insertions(+), 28 deletions(-) rename scripts/{update-github-actions.mjs => update-github-actions.ts} (65%) diff --git a/.github/workflows/update-actions.yml b/.github/workflows/update-actions.yml index 9e9a705..8248867 100644 --- a/.github/workflows/update-actions.yml +++ b/.github/workflows/update-actions.yml @@ -25,7 +25,7 @@ jobs: - name: Pin actions to latest release SHAs env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node scripts/update-github-actions.mjs + run: npx ts-node --project tsconfig.dev.json scripts/update-github-actions.ts - name: Regenerate project run: npx projen build - name: Find mutations diff --git a/.projen/tasks.json b/.projen/tasks.json index ce32d52..e27ca7e 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -132,7 +132,7 @@ }, "steps": [ { - "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern $@ src test build-tools projenrc .projenrc.ts", + "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern $@ src test build-tools scripts projenrc .projenrc.ts", "receiveArgs": true } ] diff --git a/.projenrc.ts b/.projenrc.ts index 56ff9e0..4ad3995 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -64,6 +64,12 @@ const project = new cdk.JsiiProject({ project.deps.removeDependency('commit-and-tag-version', DependencyType.BUILD); +// Include the update-github-actions maintenance script in the dev tsconfig and +// the eslint scope so it participates in lint/type-checking alongside the rest +// of the codebase. +project.tsconfigDev.addInclude('scripts/**/*.ts'); +project.eslint?.addLintPattern('scripts'); + project.addTask('local-push', { exec: 'npx yalc push' }).prependSpawn(project.buildTask); project.gitpod?.addCustomTask({ @@ -163,7 +169,7 @@ updateActionsWf?.addJobs({ { name: 'Pin actions to latest release SHAs', env: { GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }, - run: 'node scripts/update-github-actions.mjs', + run: 'npx ts-node --project tsconfig.dev.json scripts/update-github-actions.ts', }, { name: 'Regenerate project', run: 'npx projen build' }, { diff --git a/README.md b/README.md index c2b7a64..143d715 100644 --- a/README.md +++ b/README.md @@ -609,8 +609,9 @@ To keep them current, this repository runs a scheduled `update-actions` workflow 6. Opens (or updates) a single PR labelled `dependencies,github-actions` with the resolved changes. A job-summary table lists every bumped action with its old ref, new SHA, and tag. -The pinning script lives at `scripts/update-github-actions.mjs` and can be run locally -(`GH_TOKEN=$(gh auth token) node scripts/update-github-actions.mjs`) for a dry-run. +The pinning script lives at `scripts/update-github-actions.ts` and can be run locally +(`GH_TOKEN=$(gh auth token) npx ts-node --project tsconfig.dev.json scripts/update-github-actions.ts`) +for a dry-run. ### Reviewing PRs produced by `update-actions` diff --git a/scripts/update-github-actions.mjs b/scripts/update-github-actions.ts similarity index 65% rename from scripts/update-github-actions.mjs rename to scripts/update-github-actions.ts index 66ab0a7..e8c3e0a 100644 --- a/scripts/update-github-actions.mjs +++ b/scripts/update-github-actions.ts @@ -1,4 +1,3 @@ -#!/usr/bin/env node // Scans TypeScript source for `uses: 'owner/repo@ref'` literals, resolves each // action's latest stable release to a full commit SHA, and rewrites the // literals in place. The resolved tag is recorded as a trailing TypeScript @@ -10,6 +9,40 @@ import { execSync } from 'node:child_process'; import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +interface ResolvedAction { + readonly tag: string; + readonly sha: string; +} + +interface Change { + readonly file: string; + readonly repo: string; + readonly from: string; + readonly to: string; + readonly tag: string; +} + +interface GitObject { + readonly type: 'tag' | 'commit'; + readonly sha: string; +} + +interface GitRefResponse { + readonly object: GitObject; +} + +interface GitTagResponse { + readonly object: GitObject; +} + +interface Release { + readonly tag_name: string; +} + +interface Tag { + readonly name: string; +} + const ROOT = process.argv[2] ?? 'src'; const ALLOW_PRERELEASE = process.env.ALLOW_PRERELEASE === 'true'; @@ -20,8 +53,8 @@ const ALLOW_PRERELEASE = process.env.ALLOW_PRERELEASE === 'true'; // Group 4: closing `'` + optional comma, Group 5: trailing characters. const LINE_RE = /^(\s*uses:\s*')([A-Za-z0-9_.\-/]+)@([A-Za-z0-9_.\-]+)('\s*,?)([^\n]*)$/gm; -function walk(dir) { - const out = []; +function walk(dir: string): string[] { + const out: string[] = []; for (const entry of readdirSync(dir)) { const p = join(dir, entry); const s = statSync(p); @@ -31,58 +64,58 @@ function walk(dir) { return out; } -function gh(path) { +function gh(path: string): T { const raw = execSync(`gh api ${path}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - return JSON.parse(raw); + return JSON.parse(raw) as T; } -function repoRoot(useRef) { +function repoRoot(useRef: string): string { // Sub-path actions like `github/codeql-action/init` must be resolved against // the root repo. Take only the first two path segments. const [owner, repo] = useRef.split('/'); return `${owner}/${repo}`; } -function latestStableTag(repo) { +function latestStableTag(repo: string): string { if (ALLOW_PRERELEASE) { - const releases = gh(`/repos/${repo}/releases?per_page=10`); - if (Array.isArray(releases) && releases.length) return releases[0].tag_name; + const releases = gh(`/repos/${repo}/releases?per_page=10`); + if (Array.isArray(releases) && releases.length > 0) return releases[0].tag_name; } else { try { - return gh(`/repos/${repo}/releases/latest`).tag_name; + return gh(`/repos/${repo}/releases/latest`).tag_name; } catch { // Repo may not publish GitHub Releases; fall through to tags. } } - const tags = gh(`/repos/${repo}/tags?per_page=1`); + const tags = gh(`/repos/${repo}/tags?per_page=1`); if (!Array.isArray(tags) || tags.length === 0) { throw new Error(`No releases or tags found for ${repo}`); } return tags[0].name; } -function resolveSha(repo, tag) { - const ref = gh(`/repos/${repo}/git/ref/tags/${encodeURIComponent(tag)}`); - let object = ref.object; +function resolveSha(repo: string, tag: string): string { + const ref = gh(`/repos/${repo}/git/ref/tags/${encodeURIComponent(tag)}`); + let object: GitObject = ref.object; // Annotated tags point to a tag object; follow through to the commit. - while (object && object.type === 'tag') { - const t = gh(`/repos/${repo}/git/tags/${object.sha}`); + while (object.type === 'tag') { + const t = gh(`/repos/${repo}/git/tags/${object.sha}`); object = t.object; } - if (!object || !object.sha) throw new Error(`Unable to resolve SHA for ${repo}@${tag}`); + if (!object.sha) throw new Error(`Unable to resolve SHA for ${repo}@${tag}`); return object.sha; } const files = walk(ROOT); -const seen = new Map(); // repoRoot -> { tag, sha } -const changes = []; // { file, repo, from, to, tag } +const seen = new Map(); +const changes: Change[] = []; for (const file of files) { const original = readFileSync(file, 'utf8'); const updated = original.replace(LINE_RE, (line, pre, repo, ref, post, trailing) => { const root = repoRoot(repo); let target = seen.get(root); - if (!target) { + if (target === undefined) { try { const tag = latestStableTag(root); const sha = resolveSha(root, tag); @@ -90,12 +123,13 @@ for (const file of files) { seen.set(root, target); console.error(`resolved ${root} ${tag} -> ${sha}`); } catch (err) { - console.error(`skip ${root}: ${err.message}`); + const message = err instanceof Error ? err.message : String(err); + console.error(`skip ${root}: ${message}`); seen.set(root, null); return line; } } - if (!target) return line; + if (target === null) return line; if (ref === target.sha) return line; changes.push({ file, repo, from: ref, to: target.sha, tag: target.tag }); @@ -106,7 +140,7 @@ for (const file of files) { } const summaryPath = process.env.GITHUB_STEP_SUMMARY; -if (summaryPath && changes.length) { +if (summaryPath && changes.length > 0) { const lines = ['## Action updates', '', '| Action | From | To (SHA) | Tag |', '| --- | --- | --- | --- |']; for (const c of changes) { lines.push(`| \`${c.repo}\` | \`${c.from}\` | \`${c.to}\` | \`${c.tag}\` |`); diff --git a/tsconfig.dev.json b/tsconfig.dev.json index 0c1740a..0905e19 100644 --- a/tsconfig.dev.json +++ b/tsconfig.dev.json @@ -33,6 +33,7 @@ "include": [ "src/**/*.ts", "test/**/*.ts", + "scripts/**/*.ts", ".projenrc.ts", "projenrc/**/*.ts" ], From 8469af2f334eeb4772040b4f91902f928f183391 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 07:15:22 +0000 Subject: [PATCH 03/11] feat: use tsx runner and scan .projen + .projenrc.ts Replaces ts-node with tsx in the update-actions workflow, drops the default src argument, and has the script scan src/, .projen/, and .projenrc.ts for action references. Reworks the regex to also handle inline `{ name: ..., uses: ... }` entries without corrupting the surrounding properties. https://claude.ai/code/session_01Avf49PnGcNWmgpViU3uzyE --- .github/workflows/update-actions.yml | 2 +- .projen/deps.json | 4 + .projen/tasks.json | 4 +- .projenrc.ts | 3 +- README.md | 6 +- package-lock.json | 505 +++++++++++++++++++++++++++ package.json | 1 + scripts/update-github-actions.ts | 101 ++++-- 8 files changed, 589 insertions(+), 37 deletions(-) diff --git a/.github/workflows/update-actions.yml b/.github/workflows/update-actions.yml index 8248867..245abb8 100644 --- a/.github/workflows/update-actions.yml +++ b/.github/workflows/update-actions.yml @@ -25,7 +25,7 @@ jobs: - name: Pin actions to latest release SHAs env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx ts-node --project tsconfig.dev.json scripts/update-github-actions.ts + run: npx tsx scripts/update-github-actions.ts src .projen .projenrc.ts - name: Regenerate project run: npx projen build - name: Find mutations diff --git a/.projen/deps.json b/.projen/deps.json index bbf9b32..6f34099 100644 --- a/.projen/deps.json +++ b/.projen/deps.json @@ -93,6 +93,10 @@ "name": "ts-node", "type": "build" }, + { + "name": "tsx", + "type": "build" + }, { "name": "typescript", "type": "build" diff --git a/.projen/tasks.json b/.projen/tasks.json index e27ca7e..530212c 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -287,13 +287,13 @@ }, "steps": [ { - "exec": "npx npm-check-updates@20 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@types/fs-extra,@types/jest,@types/node,eslint-import-resolver-typescript,eslint-plugin-import,fs-extra,jest,jsii-diff,jsii-pacmak,projen,ts-jest,ts-node,typescript,commit-and-tag-version" + "exec": "npx npm-check-updates@20 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@types/fs-extra,@types/jest,@types/node,eslint-import-resolver-typescript,eslint-plugin-import,fs-extra,jest,jsii-diff,jsii-pacmak,projen,ts-jest,ts-node,tsx,typescript,commit-and-tag-version" }, { "exec": "npm install" }, { - "exec": "npm update @stylistic/eslint-plugin @types/fs-extra @types/jest @types/node @typescript-eslint/eslint-plugin @typescript-eslint/parser constructs eslint-import-resolver-typescript eslint-plugin-import eslint fs-extra jest jest-junit jsii-diff jsii-docgen jsii-pacmak jsii-rosetta jsii projen ts-jest ts-node typescript commit-and-tag-version" + "exec": "npm update @stylistic/eslint-plugin @types/fs-extra @types/jest @types/node @typescript-eslint/eslint-plugin @typescript-eslint/parser constructs eslint-import-resolver-typescript eslint-plugin-import eslint fs-extra jest jest-junit jsii-diff jsii-docgen jsii-pacmak jsii-rosetta jsii projen ts-jest ts-node tsx typescript commit-and-tag-version" }, { "exec": "npx projen" diff --git a/.projenrc.ts b/.projenrc.ts index 4ad3995..a20308e 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -20,6 +20,7 @@ const project = new cdk.JsiiProject({ 'constructs', 'fs-extra', '@types/fs-extra', + 'tsx', ], deps: [ 'commit-and-tag-version', @@ -169,7 +170,7 @@ updateActionsWf?.addJobs({ { name: 'Pin actions to latest release SHAs', env: { GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }, - run: 'npx ts-node --project tsconfig.dev.json scripts/update-github-actions.ts', + run: 'npx tsx scripts/update-github-actions.ts src .projen .projenrc.ts', }, { name: 'Regenerate project', run: 'npx projen build' }, { diff --git a/README.md b/README.md index 143d715..6a183ea 100644 --- a/README.md +++ b/README.md @@ -599,7 +599,7 @@ cannot see them, and versions drift over time. To keep them current, this repository runs a scheduled `update-actions` workflow (`.github/workflows/update-actions.yml`, weekly on Monday 06:00 UTC; also `workflow_dispatch`): -1. Scans `src/` for `uses: 'owner/repo@ref'` literals. +1. Scans `src/`, `.projen/`, and `.projenrc.ts` for `uses: 'owner/repo@ref'` literals. 2. For each unique action, queries the GitHub Releases API for the latest stable tag (pre-releases are skipped unless `ALLOW_PRERELEASE=true`). 3. Resolves the tag to a full commit SHA (following annotated tags to the underlying commit). @@ -610,8 +610,8 @@ To keep them current, this repository runs a scheduled `update-actions` workflow changes. A job-summary table lists every bumped action with its old ref, new SHA, and tag. The pinning script lives at `scripts/update-github-actions.ts` and can be run locally -(`GH_TOKEN=$(gh auth token) npx ts-node --project tsconfig.dev.json scripts/update-github-actions.ts`) -for a dry-run. +(`GH_TOKEN=$(gh auth token) npx tsx scripts/update-github-actions.ts src .projen .projenrc.ts`) +for a dry-run. Any number of file or directory paths may be passed as arguments. ### Reviewing PRs produced by `update-actions` diff --git a/package-lock.json b/package-lock.json index 5640149..6959f0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "projen": "0.99.49", "ts-jest": "^29.4.9", "ts-node": "^10.9.2", + "tsx": "^4.21.0", "typescript": "^5.9.3" }, "peerDependencies": { @@ -638,6 +639,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -4715,6 +5158,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -13551,6 +14036,26 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 18bc4b1..609bc28 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "projen": "0.99.49", "ts-jest": "^29.4.9", "ts-node": "^10.9.2", + "tsx": "^4.21.0", "typescript": "^5.9.3" }, "peerDependencies": { diff --git a/scripts/update-github-actions.ts b/scripts/update-github-actions.ts index e8c3e0a..add2ef6 100644 --- a/scripts/update-github-actions.ts +++ b/scripts/update-github-actions.ts @@ -43,15 +43,27 @@ interface Tag { readonly name: string; } -const ROOT = process.argv[2] ?? 'src'; +const PATHS = process.argv.slice(2); +if (PATHS.length === 0) { + console.error('usage: update-github-actions [ ...]'); + process.exit(2); +} const ALLOW_PRERELEASE = process.env.ALLOW_PRERELEASE === 'true'; -// Captures lines like: -// uses: 'actions/checkout@v6', -// uses: 'aws-actions/configure-aws-credentials@a1b2c3', // v5.0.0 -// Group 1: leading ` uses: '`, Group 2: owner/repo(/sub), Group 3: ref, -// Group 4: closing `'` + optional comma, Group 5: trailing characters. -const LINE_RE = /^(\s*uses:\s*')([A-Za-z0-9_.\-/]+)@([A-Za-z0-9_.\-]+)('\s*,?)([^\n]*)$/gm; +// Matches any `uses: 'owner/repo@ref'` literal, including inline forms like +// `{ name: 'Checkout', uses: 'actions/checkout@v6' },`. Group 1 captures the +// `uses:'` prefix, 2 the owner/repo(/sub), 3 the ref, 4 the closing quote. +const LITERAL_RE = /(uses:\s*')([A-Za-z0-9_.\-/]+)@([A-Za-z0-9_.\-]+)(')/g; + +// Matches a line whose only non-whitespace content is a `uses:` literal (plus +// optional trailing comma and optional existing `// tag` comment). Group 1 +// captures everything up to and including the closing quote + comma; group 2 +// captures any existing trailing comment, which we replace. +const STANDALONE_LINE_RE = /^(\s*uses:\s*'[A-Za-z0-9_.\-/]+@[A-Za-z0-9_.\-]+'\s*,?)(\s*\/\/[^\n]*)?$/gm; + +function isScannable(p: string): boolean { + return p.endsWith('.ts') || p.endsWith('.json') || p.endsWith('.yml') || p.endsWith('.yaml'); +} function walk(dir: string): string[] { const out: string[] = []; @@ -59,7 +71,17 @@ function walk(dir: string): string[] { const p = join(dir, entry); const s = statSync(p); if (s.isDirectory()) out.push(...walk(p)); - else if (p.endsWith('.ts')) out.push(p); + else if (isScannable(p)) out.push(p); + } + return out; +} + +function collect(paths: string[]): string[] { + const out: string[] = []; + for (const p of paths) { + const s = statSync(p); + if (s.isDirectory()) out.push(...walk(p)); + else if (isScannable(p)) out.push(p); } return out; } @@ -106,36 +128,55 @@ function resolveSha(repo: string, tag: string): string { return object.sha; } -const files = walk(ROOT); +const files = collect(PATHS); const seen = new Map(); const changes: Change[] = []; +function resolve(repoPath: string): ResolvedAction | null { + const root = repoRoot(repoPath); + const cached = seen.get(root); + if (cached !== undefined) return cached; + try { + const tag = latestStableTag(root); + const sha = resolveSha(root, tag); + const resolved: ResolvedAction = { tag, sha }; + seen.set(root, resolved); + console.error(`resolved ${root} ${tag} -> ${sha}`); + return resolved; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`skip ${root}: ${message}`); + seen.set(root, null); + return null; + } +} + for (const file of files) { const original = readFileSync(file, 'utf8'); - const updated = original.replace(LINE_RE, (line, pre, repo, ref, post, trailing) => { - const root = repoRoot(repo); - let target = seen.get(root); - if (target === undefined) { - try { - const tag = latestStableTag(root); - const sha = resolveSha(root, tag); - target = { tag, sha }; - seen.set(root, target); - console.error(`resolved ${root} ${tag} -> ${sha}`); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`skip ${root}: ${message}`); - seen.set(root, null); - return line; - } - } - if (target === null) return line; - if (ref === target.sha) return line; + const tagByRepo = new Map(); + // Pass 1: replace the ref with the resolved SHA in every `uses:` literal, + // including inline occurrences that share their line with other properties. + let updated = original.replace(LITERAL_RE, (match, pre, repo, ref, quote) => { + const target = resolve(repo); + if (!target || ref === target.sha) return match; changes.push({ file, repo, from: ref, to: target.sha, tag: target.tag }); - const stripped = trailing.replace(/\s*\/\/.*$/, '').trimEnd(); - return `${pre}${repo}@${target.sha}${post}${stripped} // ${target.tag}`.trimEnd(); + tagByRepo.set(repo, target.tag); + return `${pre}${repo}@${target.sha}${quote}`; + }); + + // Pass 2: on lines whose only content is a single `uses:` literal, insert or + // refresh a trailing `// ` comment so reviewers see the human-readable + // version alongside the SHA. Inline forms are skipped to avoid corrupting + // surrounding properties. + updated = updated.replace(STANDALONE_LINE_RE, (line, head) => { + const m = /'([A-Za-z0-9_.\-/]+)@[A-Za-z0-9_.\-]+'/.exec(head); + if (!m) return line; + const tag = tagByRepo.get(m[1]) ?? seen.get(repoRoot(m[1]))?.tag; + if (!tag) return line; + return `${head.trimEnd()} // ${tag}`; }); + if (updated !== original) writeFileSync(file, updated); } From b21441a9dfec0b0ab5fa6a1e85d17d116d542052 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 08:05:10 +0000 Subject: [PATCH 04/11] refactor: move update-github-actions into src/security/ Relocates the action-pinning maintenance script into the main source tree so it's covered by the standard tsconfig and eslint scope without bespoke includes. Drops the now-unnecessary `scripts/` directory and its projenrc hooks; the workflow invokes the script at its new path. https://claude.ai/code/session_01Avf49PnGcNWmgpViU3uzyE --- .github/workflows/update-actions.yml | 2 +- .projen/tasks.json | 2 +- .projenrc.ts | 8 +------- README.md | 4 ++-- {scripts => src/security}/update-github-actions.ts | 11 ++++++----- tsconfig.dev.json | 1 - 6 files changed, 11 insertions(+), 17 deletions(-) rename {scripts => src/security}/update-github-actions.ts (96%) diff --git a/.github/workflows/update-actions.yml b/.github/workflows/update-actions.yml index 245abb8..b47fef4 100644 --- a/.github/workflows/update-actions.yml +++ b/.github/workflows/update-actions.yml @@ -25,7 +25,7 @@ jobs: - name: Pin actions to latest release SHAs env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx tsx scripts/update-github-actions.ts src .projen .projenrc.ts + run: npx tsx src/security/update-github-actions.ts src .projen .projenrc.ts - name: Regenerate project run: npx projen build - name: Find mutations diff --git a/.projen/tasks.json b/.projen/tasks.json index 530212c..1cc3c1a 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -132,7 +132,7 @@ }, "steps": [ { - "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern $@ src test build-tools scripts projenrc .projenrc.ts", + "exec": "eslint --ext .ts,.tsx --fix --no-error-on-unmatched-pattern $@ src test build-tools projenrc .projenrc.ts", "receiveArgs": true } ] diff --git a/.projenrc.ts b/.projenrc.ts index a20308e..2683559 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -65,12 +65,6 @@ const project = new cdk.JsiiProject({ project.deps.removeDependency('commit-and-tag-version', DependencyType.BUILD); -// Include the update-github-actions maintenance script in the dev tsconfig and -// the eslint scope so it participates in lint/type-checking alongside the rest -// of the codebase. -project.tsconfigDev.addInclude('scripts/**/*.ts'); -project.eslint?.addLintPattern('scripts'); - project.addTask('local-push', { exec: 'npx yalc push' }).prependSpawn(project.buildTask); project.gitpod?.addCustomTask({ @@ -170,7 +164,7 @@ updateActionsWf?.addJobs({ { name: 'Pin actions to latest release SHAs', env: { GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }, - run: 'npx tsx scripts/update-github-actions.ts src .projen .projenrc.ts', + run: 'npx tsx src/security/update-github-actions.ts src .projen .projenrc.ts', }, { name: 'Regenerate project', run: 'npx projen build' }, { diff --git a/README.md b/README.md index 6a183ea..7b87855 100644 --- a/README.md +++ b/README.md @@ -609,8 +609,8 @@ To keep them current, this repository runs a scheduled `update-actions` workflow 6. Opens (or updates) a single PR labelled `dependencies,github-actions` with the resolved changes. A job-summary table lists every bumped action with its old ref, new SHA, and tag. -The pinning script lives at `scripts/update-github-actions.ts` and can be run locally -(`GH_TOKEN=$(gh auth token) npx tsx scripts/update-github-actions.ts src .projen .projenrc.ts`) +The pinning script lives at `src/security/update-github-actions.ts` and can be run locally +(`GH_TOKEN=$(gh auth token) npx tsx src/security/update-github-actions.ts src .projen .projenrc.ts`) for a dry-run. Any number of file or directory paths may be passed as arguments. ### Reviewing PRs produced by `update-actions` diff --git a/scripts/update-github-actions.ts b/src/security/update-github-actions.ts similarity index 96% rename from scripts/update-github-actions.ts rename to src/security/update-github-actions.ts index add2ef6..bfa77e5 100644 --- a/scripts/update-github-actions.ts +++ b/src/security/update-github-actions.ts @@ -1,13 +1,14 @@ -// Scans TypeScript source for `uses: 'owner/repo@ref'` literals, resolves each -// action's latest stable release to a full commit SHA, and rewrites the +#!/usr/bin/env node +// Scans configured source paths for `uses: 'owner/repo@ref'` literals, resolves +// each action to the latest stable release's commit SHA, and rewrites the // literals in place. The resolved tag is recorded as a trailing TypeScript // comment so reviewers can see the human-readable version alongside the SHA. // // Relies on the `gh` CLI for authenticated GitHub API access (GH_TOKEN env). -import { execSync } from 'node:child_process'; -import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { execSync } from 'child_process'; +import { readdirSync, readFileSync, statSync, writeFileSync } from 'fs'; +import { join } from 'path'; interface ResolvedAction { readonly tag: string; diff --git a/tsconfig.dev.json b/tsconfig.dev.json index 0905e19..0c1740a 100644 --- a/tsconfig.dev.json +++ b/tsconfig.dev.json @@ -33,7 +33,6 @@ "include": [ "src/**/*.ts", "test/**/*.ts", - "scripts/**/*.ts", ".projenrc.ts", "projenrc/**/*.ts" ], From 6b40283ed34f7c28eba73325caa526001a2d4ab7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 08:19:10 +0000 Subject: [PATCH 05/11] feat: expose update-actions workflow and bin for downstream consumers Adds a `update-github-actions` bin entry pointing at the compiled pinning script, and introduces `UpdateActionsWorkflow` as a reusable projen Component so downstream projects can add the same scheduled maintenance workflow to their own pipelines. This addresses the stretch goal from #186. The repo's own `.projenrc.ts` now consumes the new class with a `command` override that runs the script from source (since the bin isn't yet installed when this package builds itself). https://claude.ai/code/session_01Avf49PnGcNWmgpViU3uzyE --- .github/workflows/update-actions.yml | 2 +- .projenrc.ts | 110 +------- API.md | 355 ++++++++++++++++++++++++ README.md | 24 +- package-lock.json | 3 +- package.json | 3 +- src/index.ts | 1 + src/security/index.ts | 1 + src/security/update-actions-workflow.ts | 236 ++++++++++++++++ 9 files changed, 625 insertions(+), 110 deletions(-) create mode 100644 src/security/index.ts create mode 100644 src/security/update-actions-workflow.ts diff --git a/.github/workflows/update-actions.yml b/.github/workflows/update-actions.yml index b47fef4..3372514 100644 --- a/.github/workflows/update-actions.yml +++ b/.github/workflows/update-actions.yml @@ -77,7 +77,7 @@ jobs: title: "chore(deps): update pinned GitHub Actions" labels: auto-approve,dependencies,github-actions body: |- - Pins action references in `src/` to the latest stable release commit SHAs. + Pins action references to the latest stable release commit SHAs. See the job summary of the [workflow run] for a per-action diff. diff --git a/.projenrc.ts b/.projenrc.ts index 2683559..cd1df26 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -1,6 +1,7 @@ import { DependencyType, ReleasableCommits, cdk, github, javascript } from 'projen'; import { JobPermission } from 'projen/lib/github/workflows-model'; import { GitHubAssignApprover } from './src/assign-approver'; +import { UpdateActionsWorkflow } from './src/security'; const project = new cdk.JsiiProject({ author: 'The Open Construct Foundation', @@ -52,6 +53,7 @@ const project = new cdk.JsiiProject({ bin: { 'pipelines-release': 'lib/release.js', 'detect-drift': 'lib/drift/detect-drift.js', + 'update-github-actions': 'lib/security/update-github-actions.js', }, releaseToNpm: true, npmTrustedPublishing: true, @@ -140,110 +142,10 @@ new GitHubAssignApprover(project, { // pin them to the latest stable release's commit SHA, and open a PR with the // result. Keeps generated pipelines on current, security-patched actions // despite them living as string literals (invisible to Dependabot/Renovate). -const updateActionsWf = project.github?.addWorkflow('update-actions'); -updateActionsWf?.on({ - workflowDispatch: {}, - schedule: [{ cron: '0 6 * * 1' }], -}); -updateActionsWf?.addJobs({ - upgrade: { - name: 'Upgrade GitHub Actions', - runsOn: ['ubuntu-latest'], - permissions: { contents: JobPermission.READ }, - outputs: { - patch_created: { stepId: 'create_patch', outputName: 'patch_created' }, - }, - steps: [ - { name: 'Checkout', uses: 'actions/checkout@v6' }, - { - name: 'Setup Node', - uses: 'actions/setup-node@v6', - with: { 'node-version': '22' }, - }, - { name: 'Install dependencies', run: 'npm ci' }, - { - name: 'Pin actions to latest release SHAs', - env: { GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }, - run: 'npx tsx src/security/update-github-actions.ts src .projen .projenrc.ts', - }, - { name: 'Regenerate project', run: 'npx projen build' }, - { - name: 'Find mutations', - id: 'create_patch', - shell: 'bash', - run: [ - 'git add .', - 'git diff --staged --patch --exit-code > repo.patch || echo "patch_created=true" >> $GITHUB_OUTPUT', - ].join('\n'), - }, - { - name: 'Upload patch', - if: 'steps.create_patch.outputs.patch_created', - uses: 'actions/upload-artifact@v7', - with: { name: 'repo.patch', path: 'repo.patch', overwrite: true }, - }, - ], - }, - pr: { - name: 'Create Pull Request', - needs: ['upgrade'], - runsOn: ['ubuntu-latest'], - permissions: { contents: JobPermission.READ }, - if: '${{ needs.upgrade.outputs.patch_created }}', - steps: [ - { - name: 'Generate token', - id: 'generate_token', - uses: 'actions/create-github-app-token@v2', - with: { - 'app-id': '${{ secrets.PROJEN_APP_ID }}', - 'private-key': '${{ secrets.PROJEN_APP_PRIVATE_KEY }}', - }, - }, - { name: 'Checkout', uses: 'actions/checkout@v6' }, - { - name: 'Download patch', - uses: 'actions/download-artifact@v8', - with: { name: 'repo.patch', path: '${{ runner.temp }}' }, - }, - { - name: 'Apply patch', - run: '[ -s ${{ runner.temp }}/repo.patch ] && git apply ${{ runner.temp }}/repo.patch || echo "Empty patch. Skipping."', - }, - { - name: 'Set git identity', - run: [ - 'git config user.name "github-actions[bot]"', - 'git config user.email "41898282+github-actions[bot]@users.noreply.github.com"', - ].join('\n'), - }, - { - name: 'Create Pull Request', - uses: 'peter-evans/create-pull-request@v8', - with: { - 'token': '${{ steps.generate_token.outputs.token }}', - 'commit-message': 'chore(deps): pin github actions to latest release SHAs', - 'branch': 'github-actions/update-actions', - 'title': 'chore(deps): update pinned GitHub Actions', - 'labels': 'auto-approve,dependencies,github-actions', - 'body': [ - 'Pins action references in `src/` to the latest stable release commit SHAs.', - '', - 'See the job summary of the [workflow run] for a per-action diff.', - '', - '[Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}', - '', - '------', - '', - '*Automatically created by the `update-actions` workflow.*', - ].join('\n'), - 'author': 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>', - 'committer': 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>', - 'signoff': true, - }, - }, - ], - }, +// This package self-hosts the script from source so the workflow can run +// before the `update-github-actions` bin is installed into node_modules. +new UpdateActionsWorkflow(project, { + command: 'npx tsx src/security/update-github-actions.ts', }); project.synth(); \ No newline at end of file diff --git a/API.md b/API.md index 9ba28f4..4058d6c 100644 --- a/API.md +++ b/API.md @@ -2352,6 +2352,205 @@ public readonly schedule: string; --- +### UpdateActionsWorkflow + +Adds an `update-actions` workflow that pins GitHub Action references to the latest stable release commit SHAs and opens a PR with the result. + +The workflow scans source files (TypeScript, JSON, YAML) for +`uses: 'owner/repo@ref'` literals, resolves each action's latest release to +a full commit SHA via the GitHub API, rewrites the literals in place, runs +`npx projen build` to regenerate outputs, and opens a single PR with the +results and a per-action summary. + +```ts +import { UpdateActionsWorkflow } from 'projen-pipelines'; + +new UpdateActionsWorkflow(project); +``` + +#### Initializers + +```typescript +import { UpdateActionsWorkflow } from 'projen-pipelines' + +new UpdateActionsWorkflow(scope: GitHubProject, options?: UpdateActionsWorkflowOptions) +``` + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| scope | projen.github.GitHubProject | *No description.* | +| options | UpdateActionsWorkflowOptions | *No description.* | + +--- + +##### `scope`Required + +- *Type:* projen.github.GitHubProject + +--- + +##### `options`Optional + +- *Type:* UpdateActionsWorkflowOptions + +--- + +#### Methods + +| **Name** | **Description** | +| --- | --- | +| toString | Returns a string representation of this construct. | +| with | Applies one or more mixins to this construct. | +| postSynthesize | Called after synthesis. | +| preSynthesize | Called before synthesis. | +| synthesize | Synthesizes files to the project output directory. | + +--- + +##### `toString` + +```typescript +public toString(): string +``` + +Returns a string representation of this construct. + +##### `with` + +```typescript +public with(mixins: ...IMixin[]): IConstruct +``` + +Applies one or more mixins to this construct. + +Mixins are applied in order. The list of constructs is captured at the +start of the call, so constructs added by a mixin will not be visited. +Use multiple `with()` calls if subsequent mixins should apply to added +constructs. + +###### `mixins`Required + +- *Type:* ...constructs.IMixin[] + +The mixins to apply. + +--- + +##### `postSynthesize` + +```typescript +public postSynthesize(): void +``` + +Called after synthesis. + +Order is *not* guaranteed. + +##### `preSynthesize` + +```typescript +public preSynthesize(): void +``` + +Called before synthesis. + +##### `synthesize` + +```typescript +public synthesize(): void +``` + +Synthesizes files to the project output directory. + +#### Static Functions + +| **Name** | **Description** | +| --- | --- | +| isConstruct | Checks if `x` is a construct. | +| isComponent | Test whether the given construct is a component. | + +--- + +##### `isConstruct` + +```typescript +import { UpdateActionsWorkflow } from 'projen-pipelines' + +UpdateActionsWorkflow.isConstruct(x: any) +``` + +Checks if `x` is a construct. + +Use this method instead of `instanceof` to properly detect `Construct` +instances, even when the construct library is symlinked. + +Explanation: in JavaScript, multiple copies of the `constructs` library on +disk are seen as independent, completely different libraries. As a +consequence, the class `Construct` in each copy of the `constructs` library +is seen as a different class, and an instance of one class will not test as +`instanceof` the other class. `npm install` will not create installations +like this, but users may manually symlink construct libraries together or +use a monorepo tool: in those cases, multiple copies of the `constructs` +library can be accidentally installed, and `instanceof` will behave +unpredictably. It is safest to avoid using `instanceof`, and using +this type-testing method instead. + +###### `x`Required + +- *Type:* any + +Any object. + +--- + +##### `isComponent` + +```typescript +import { UpdateActionsWorkflow } from 'projen-pipelines' + +UpdateActionsWorkflow.isComponent(x: any) +``` + +Test whether the given construct is a component. + +###### `x`Required + +- *Type:* any + +--- + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| node | constructs.Node | The tree node. | +| project | projen.Project | *No description.* | + +--- + +##### `node`Required + +```typescript +public readonly node: Node; +``` + +- *Type:* constructs.Node + +The tree node. + +--- + +##### `project`Required + +```typescript +public readonly project: Project; +``` + +- *Type:* projen.Project + +--- + + ## Structs ### AmplifyDeployStepConfig @@ -6378,6 +6577,162 @@ public readonly parameterName: string; --- +### UpdateActionsWorkflowOptions + +Options for the {@link UpdateActionsWorkflow} maintenance workflow. + +#### Initializer + +```typescript +import { UpdateActionsWorkflowOptions } from 'projen-pipelines' + +const updateActionsWorkflowOptions: UpdateActionsWorkflowOptions = { ... } +``` + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| allowPrerelease | boolean | Include pre-releases when resolving the latest stable tag. | +| branch | string | Branch name used for the pull request. | +| command | string | Shell command that invokes the pinning script. | +| labels | string[] | Labels applied to the pull request created by the workflow. | +| paths | string[] | File or directory paths to scan for `uses: 'owner/repo@ref'` literals. | +| runnerTags | string[] | Runner tags for both jobs in the workflow. | +| schedule | string | Cron expression for the scheduled run. | +| tokenAppIdSecret | string | Name of the GitHub secret holding the GitHub App ID used to create the PR. | +| tokenAppPrivateKeySecret | string | Name of the GitHub secret holding the GitHub App private key. | + +--- + +##### `allowPrerelease`Optional + +```typescript +public readonly allowPrerelease: boolean; +``` + +- *Type:* boolean +- *Default:* false + +Include pre-releases when resolving the latest stable tag. + +--- + +##### `branch`Optional + +```typescript +public readonly branch: string; +``` + +- *Type:* string +- *Default:* 'github-actions/update-actions' + +Branch name used for the pull request. + +--- + +##### `command`Optional + +```typescript +public readonly command: string; +``` + +- *Type:* string +- *Default:* 'npx update-github-actions' + +Shell command that invokes the pinning script. + +For projects that install `projen-pipelines` as a dependency, the default +`npx update-github-actions` resolves the bin exposed by the package. When +this library maintains itself, the source location is used instead. + +--- + +##### `labels`Optional + +```typescript +public readonly labels: string[]; +``` + +- *Type:* string[] +- *Default:* ['auto-approve', 'dependencies', 'github-actions'] + +Labels applied to the pull request created by the workflow. + +--- + +##### `paths`Optional + +```typescript +public readonly paths: string[]; +``` + +- *Type:* string[] +- *Default:* ['src', '.projen', '.projenrc.ts'] + +File or directory paths to scan for `uses: 'owner/repo@ref'` literals. + +Directories are walked recursively for `.ts`, `.json`, `.yml`, and `.yaml` +files; individual files are scanned directly. + +--- + +##### `runnerTags`Optional + +```typescript +public readonly runnerTags: string[]; +``` + +- *Type:* string[] +- *Default:* ['ubuntu-latest'] + +Runner tags for both jobs in the workflow. + +--- + +##### `schedule`Optional + +```typescript +public readonly schedule: string; +``` + +- *Type:* string +- *Default:* '0 6 * * 1' (weekly on Monday at 06:00 UTC) + +Cron expression for the scheduled run. + +--- + +##### `tokenAppIdSecret`Optional + +```typescript +public readonly tokenAppIdSecret: string; +``` + +- *Type:* string +- *Default:* 'PROJEN_APP_ID' + +Name of the GitHub secret holding the GitHub App ID used to create the PR. + +The app token is preferred over the default `GITHUB_TOKEN` so that CI runs +on the created PR. Set to an empty string to skip the app-token step and +fall back to the workflow's default token. + +--- + +##### `tokenAppPrivateKeySecret`Optional + +```typescript +public readonly tokenAppPrivateKeySecret: string; +``` + +- *Type:* string +- *Default:* 'PROJEN_APP_PRIVATE_KEY' + +Name of the GitHub secret holding the GitHub App private key. + +--- + ### UploadArtifactStepConfig #### Initializer diff --git a/README.md b/README.md index 7b87855..c987f37 100644 --- a/README.md +++ b/README.md @@ -609,9 +609,27 @@ To keep them current, this repository runs a scheduled `update-actions` workflow 6. Opens (or updates) a single PR labelled `dependencies,github-actions` with the resolved changes. A job-summary table lists every bumped action with its old ref, new SHA, and tag. -The pinning script lives at `src/security/update-github-actions.ts` and can be run locally -(`GH_TOKEN=$(gh auth token) npx tsx src/security/update-github-actions.ts src .projen .projenrc.ts`) -for a dry-run. Any number of file or directory paths may be passed as arguments. +The pinning script is exposed as the `update-github-actions` bin of this package, so a local +dry-run is `GH_TOKEN=$(gh auth token) npx update-github-actions src .projen .projenrc.ts`. Any +number of file or directory paths may be passed as arguments. The script source lives at +`src/security/update-github-actions.ts`. + +### Opting in from a downstream project + +Downstream projects that depend on `projen-pipelines` can enable the same maintenance workflow +for themselves by adding the `UpdateActionsWorkflow` component to their `.projenrc.ts`: + +```ts +import { UpdateActionsWorkflow } from 'projen-pipelines'; + +new UpdateActionsWorkflow(project); +``` + +Options cover the cron schedule, the paths to scan, the PR branch and labels, and the names of +the GitHub App secrets used to open the pull request. Without overrides the workflow scans +`src`, `.projen`, and `.projenrc.ts` on a weekly schedule and opens a PR via the +`PROJEN_APP_ID` / `PROJEN_APP_PRIVATE_KEY` app token. Pass `tokenAppIdSecret: ''` to fall back +to the default `GITHUB_TOKEN`. ### Reviewing PRs produced by `update-actions` diff --git a/package-lock.json b/package-lock.json index 6959f0e..df1160b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ }, "bin": { "detect-drift": "lib/drift/detect-drift.js", - "pipelines-release": "lib/release.js" + "pipelines-release": "lib/release.js", + "update-github-actions": "lib/security/update-github-actions.js" }, "devDependencies": { "@stylistic/eslint-plugin": "^2", diff --git a/package.json b/package.json index 609bc28..4511f1e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ }, "bin": { "detect-drift": "lib/drift/detect-drift.js", - "pipelines-release": "lib/release.js" + "pipelines-release": "lib/release.js", + "update-github-actions": "lib/security/update-github-actions.js" }, "scripts": { "build": "projen build", diff --git a/src/index.ts b/src/index.ts index 15ad670..7d85daf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,5 @@ export * from './steps'; export * from './awscdk'; export * from './assign-approver'; export * from './drift'; +export * from './security'; export * from './versioning'; diff --git a/src/security/index.ts b/src/security/index.ts new file mode 100644 index 0000000..d9eddbb --- /dev/null +++ b/src/security/index.ts @@ -0,0 +1 @@ +export * from './update-actions-workflow'; diff --git a/src/security/update-actions-workflow.ts b/src/security/update-actions-workflow.ts new file mode 100644 index 0000000..5ad817c --- /dev/null +++ b/src/security/update-actions-workflow.ts @@ -0,0 +1,236 @@ +import { Component } from 'projen'; +import { GitHubProject } from 'projen/lib/github'; +import { Job, JobPermission } from 'projen/lib/github/workflows-model'; + +/** + * Options for the {@link UpdateActionsWorkflow} maintenance workflow. + */ +export interface UpdateActionsWorkflowOptions { + /** + * File or directory paths to scan for `uses: 'owner/repo@ref'` literals. + * + * Directories are walked recursively for `.ts`, `.json`, `.yml`, and `.yaml` + * files; individual files are scanned directly. + * + * @default ['src', '.projen', '.projenrc.ts'] + */ + readonly paths?: string[]; + + /** + * Cron expression for the scheduled run. + * + * @default '0 6 * * 1' (weekly on Monday at 06:00 UTC) + */ + readonly schedule?: string; + + /** + * Runner tags for both jobs in the workflow. + * + * @default ['ubuntu-latest'] + */ + readonly runnerTags?: string[]; + + /** + * Labels applied to the pull request created by the workflow. + * + * @default ['auto-approve', 'dependencies', 'github-actions'] + */ + readonly labels?: string[]; + + /** + * Branch name used for the pull request. + * + * @default 'github-actions/update-actions' + */ + readonly branch?: string; + + /** + * Include pre-releases when resolving the latest stable tag. + * + * @default false + */ + readonly allowPrerelease?: boolean; + + /** + * Shell command that invokes the pinning script. + * + * For projects that install `projen-pipelines` as a dependency, the default + * `npx update-github-actions` resolves the bin exposed by the package. When + * this library maintains itself, the source location is used instead. + * + * @default 'npx update-github-actions' + */ + readonly command?: string; + + /** + * Name of the GitHub secret holding the GitHub App ID used to create the PR. + * + * The app token is preferred over the default `GITHUB_TOKEN` so that CI runs + * on the created PR. Set to an empty string to skip the app-token step and + * fall back to the workflow's default token. + * + * @default 'PROJEN_APP_ID' + */ + readonly tokenAppIdSecret?: string; + + /** + * Name of the GitHub secret holding the GitHub App private key. + * + * @default 'PROJEN_APP_PRIVATE_KEY' + */ + readonly tokenAppPrivateKeySecret?: string; +} + +/** + * Adds an `update-actions` workflow that pins GitHub Action references to the + * latest stable release commit SHAs and opens a PR with the result. + * + * The workflow scans source files (TypeScript, JSON, YAML) for + * `uses: 'owner/repo@ref'` literals, resolves each action's latest release to + * a full commit SHA via the GitHub API, rewrites the literals in place, runs + * `npx projen build` to regenerate outputs, and opens a single PR with the + * results and a per-action summary. + * + * ```ts + * import { UpdateActionsWorkflow } from 'projen-pipelines'; + * + * new UpdateActionsWorkflow(project); + * ``` + */ +export class UpdateActionsWorkflow extends Component { + constructor(scope: GitHubProject, options: UpdateActionsWorkflowOptions = {}) { + super(scope); + + if (!scope.github) { + throw new Error('UpdateActionsWorkflow requires a GitHubProject with github integration enabled.'); + } + + const paths = options.paths ?? ['src', '.projen', '.projenrc.ts']; + const schedule = options.schedule ?? '0 6 * * 1'; + const runsOn = options.runnerTags ?? ['ubuntu-latest']; + const labels = options.labels ?? ['auto-approve', 'dependencies', 'github-actions']; + const branch = options.branch ?? 'github-actions/update-actions'; + const command = options.command ?? 'npx update-github-actions'; + const appIdSecret = options.tokenAppIdSecret ?? 'PROJEN_APP_ID'; + const appKeySecret = options.tokenAppPrivateKeySecret ?? 'PROJEN_APP_PRIVATE_KEY'; + const useAppToken = appIdSecret !== '' && appKeySecret !== ''; + + const workflow = scope.github.addWorkflow('update-actions'); + workflow.on({ + workflowDispatch: {}, + schedule: [{ cron: schedule }], + }); + + const env: Record = { GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' }; + if (options.allowPrerelease) env.ALLOW_PRERELEASE = 'true'; + + const upgrade: Job = { + name: 'Upgrade GitHub Actions', + runsOn: runsOn, + permissions: { contents: JobPermission.READ }, + outputs: { + patch_created: { stepId: 'create_patch', outputName: 'patch_created' }, + }, + steps: [ + { name: 'Checkout', uses: 'actions/checkout@v6' }, + { + name: 'Setup Node', + uses: 'actions/setup-node@v6', + with: { 'node-version': '22' }, + }, + { name: 'Install dependencies', run: 'npm ci' }, + { + name: 'Pin actions to latest release SHAs', + env, + run: `${command} ${paths.join(' ')}`, + }, + { name: 'Regenerate project', run: 'npx projen build' }, + { + name: 'Find mutations', + id: 'create_patch', + shell: 'bash', + run: [ + 'git add .', + 'git diff --staged --patch --exit-code > repo.patch || echo "patch_created=true" >> $GITHUB_OUTPUT', + ].join('\n'), + }, + { + name: 'Upload patch', + if: 'steps.create_patch.outputs.patch_created', + uses: 'actions/upload-artifact@v7', + with: { name: 'repo.patch', path: 'repo.patch', overwrite: true }, + }, + ], + }; + + const prSteps = []; + if (useAppToken) { + prSteps.push({ + name: 'Generate token', + id: 'generate_token', + uses: 'actions/create-github-app-token@v2', + with: { + 'app-id': `\${{ secrets.${appIdSecret} }}`, + 'private-key': `\${{ secrets.${appKeySecret} }}`, + }, + }); + } + prSteps.push( + { name: 'Checkout', uses: 'actions/checkout@v6' }, + { + name: 'Download patch', + uses: 'actions/download-artifact@v8', + with: { name: 'repo.patch', path: '${{ runner.temp }}' }, + }, + { + name: 'Apply patch', + run: '[ -s ${{ runner.temp }}/repo.patch ] && git apply ${{ runner.temp }}/repo.patch || echo "Empty patch. Skipping."', + }, + { + name: 'Set git identity', + run: [ + 'git config user.name "github-actions[bot]"', + 'git config user.email "41898282+github-actions[bot]@users.noreply.github.com"', + ].join('\n'), + }, + { + name: 'Create Pull Request', + uses: 'peter-evans/create-pull-request@v8', + with: { + 'token': useAppToken + ? '${{ steps.generate_token.outputs.token }}' + : '${{ secrets.GITHUB_TOKEN }}', + 'commit-message': 'chore(deps): pin github actions to latest release SHAs', + 'branch': branch, + 'title': 'chore(deps): update pinned GitHub Actions', + 'labels': labels.join(','), + 'body': [ + 'Pins action references to the latest stable release commit SHAs.', + '', + 'See the job summary of the [workflow run] for a per-action diff.', + '', + '[Workflow Run]: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}', + '', + '------', + '', + '*Automatically created by the `update-actions` workflow.*', + ].join('\n'), + 'author': 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>', + 'committer': 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>', + 'signoff': true, + }, + }, + ); + + const pr: Job = { + name: 'Create Pull Request', + needs: ['upgrade'], + runsOn: runsOn, + permissions: { contents: JobPermission.READ }, + if: '${{ needs.upgrade.outputs.patch_created }}', + steps: prSteps, + }; + + workflow.addJobs({ upgrade, pr }); + } +} From 977258f39d34c4711ec9614092872ab4d94f3efe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 08:24:37 +0000 Subject: [PATCH 06/11] fix: scope default scan paths to projen-managed files Downstream consumers of UpdateActionsWorkflow have their action references in `.projenrc.ts` and `.projen/` rather than in their application source, so the default now targets those paths only. `projen-pipelines` itself extends the list with `src` since it embeds action strings in its hand-authored library code. https://claude.ai/code/session_01Avf49PnGcNWmgpViU3uzyE --- .projenrc.ts | 5 ++++- README.md | 8 ++++++-- src/security/update-actions-workflow.ts | 10 ++++++++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.projenrc.ts b/.projenrc.ts index cd1df26..afc9c03 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -143,8 +143,11 @@ new GitHubAssignApprover(project, { // result. Keeps generated pipelines on current, security-patched actions // despite them living as string literals (invisible to Dependabot/Renovate). // This package self-hosts the script from source so the workflow can run -// before the `update-github-actions` bin is installed into node_modules. +// before the `update-github-actions` bin is installed into node_modules, and +// it extends the default paths with `src` because projen-pipelines embeds +// action strings in its hand-authored library code. new UpdateActionsWorkflow(project, { + paths: ['src', '.projen', '.projenrc.ts'], command: 'npx tsx src/security/update-github-actions.ts', }); diff --git a/README.md b/README.md index c987f37..a34ebc5 100644 --- a/README.md +++ b/README.md @@ -626,8 +626,12 @@ new UpdateActionsWorkflow(project); ``` Options cover the cron schedule, the paths to scan, the PR branch and labels, and the names of -the GitHub App secrets used to open the pull request. Without overrides the workflow scans -`src`, `.projen`, and `.projenrc.ts` on a weekly schedule and opens a PR via the +the GitHub App secrets used to open the pull request. The default scans only +projen-managed files (`.projen/` and `.projenrc.ts`), which is the right scope for most +consumers — their action references live in the projen configuration, not in hand-authored +source. Projects that embed action strings directly in their library code (such as +`projen-pipelines` itself) should extend the list with `paths: ['src', '.projen', '.projenrc.ts']`. +Without overrides the workflow runs weekly and opens a PR via the `PROJEN_APP_ID` / `PROJEN_APP_PRIVATE_KEY` app token. Pass `tokenAppIdSecret: ''` to fall back to the default `GITHUB_TOKEN`. diff --git a/src/security/update-actions-workflow.ts b/src/security/update-actions-workflow.ts index 5ad817c..62db48c 100644 --- a/src/security/update-actions-workflow.ts +++ b/src/security/update-actions-workflow.ts @@ -12,7 +12,13 @@ export interface UpdateActionsWorkflowOptions { * Directories are walked recursively for `.ts`, `.json`, `.yml`, and `.yaml` * files; individual files are scanned directly. * - * @default ['src', '.projen', '.projenrc.ts'] + * The default targets projen-managed files only, which is the right scope + * for downstream consumers: their action references live in `.projenrc.ts` + * and `.projen/` rather than in their application source. Projects that + * embed action strings in hand-authored source (such as `projen-pipelines` + * itself) should extend this list with `'src'`. + * + * @default ['.projen', '.projenrc.ts'] */ readonly paths?: string[]; @@ -105,7 +111,7 @@ export class UpdateActionsWorkflow extends Component { throw new Error('UpdateActionsWorkflow requires a GitHubProject with github integration enabled.'); } - const paths = options.paths ?? ['src', '.projen', '.projenrc.ts']; + const paths = options.paths ?? ['.projen', '.projenrc.ts']; const schedule = options.schedule ?? '0 6 * * 1'; const runsOn = options.runnerTags ?? ['ubuntu-latest']; const labels = options.labels ?? ['auto-approve', 'dependencies', 'github-actions']; From 2976ba41ff83f5e4434cb9af4a28082bc8d13915 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:25:46 +0000 Subject: [PATCH 07/11] chore: self mutation Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- API.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/API.md b/API.md index 4058d6c..a4bb36d 100644 --- a/API.md +++ b/API.md @@ -6668,13 +6668,19 @@ public readonly paths: string[]; ``` - *Type:* string[] -- *Default:* ['src', '.projen', '.projenrc.ts'] +- *Default:* ['.projen', '.projenrc.ts'] File or directory paths to scan for `uses: 'owner/repo@ref'` literals. Directories are walked recursively for `.ts`, `.json`, `.yml`, and `.yaml` files; individual files are scanned directly. +The default targets projen-managed files only, which is the right scope +for downstream consumers: their action references live in `.projenrc.ts` +and `.projen/` rather than in their application source. Projects that +embed action strings in hand-authored source (such as `projen-pipelines` +itself) should extend this list with `'src'`. + --- ##### `runnerTags`Optional From bc25a3da45235703248b306cdb36db2ea09cfe71 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 08:31:24 +0000 Subject: [PATCH 08/11] test: add UpdateActionsWorkflow unit tests Covers default path scoping, custom paths/command/schedule/runner/labels/ branch/prerelease overrides, GitHub App token vs GITHUB_TOKEN fallback, job outputs, and the defensive error when github integration is disabled. Achieves 100% coverage for the new component. https://claude.ai/code/session_01Avf49PnGcNWmgpViU3uzyE --- .../update-actions-workflow.test.ts.snap | 96 ++++++++++ test/update-actions-workflow.test.ts | 164 ++++++++++++++++++ 2 files changed, 260 insertions(+) create mode 100644 test/__snapshots__/update-actions-workflow.test.ts.snap create mode 100644 test/update-actions-workflow.test.ts diff --git a/test/__snapshots__/update-actions-workflow.test.ts.snap b/test/__snapshots__/update-actions-workflow.test.ts.snap new file mode 100644 index 0000000..4ceac3d --- /dev/null +++ b/test/__snapshots__/update-actions-workflow.test.ts.snap @@ -0,0 +1,96 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UpdateActionsWorkflow matches snapshot with defaults 1`] = ` +"# ~~ Generated by projen. To modify, edit .projenrc.js and run "npx projen". + +name: update-actions +on: + workflow_dispatch: {} + schedule: + - cron: 0 6 * * 1 +jobs: + upgrade: + name: Upgrade GitHub Actions + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + patch_created: \${{ steps.create_patch.outputs.patch_created }} + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Install dependencies + run: npm ci + - name: Pin actions to latest release SHAs + env: + GH_TOKEN: \${{ secrets.GITHUB_TOKEN }} + run: npx update-github-actions .projen .projenrc.ts + - name: Regenerate project + run: npx projen build + - name: Find mutations + id: create_patch + run: |- + git add . + git diff --staged --patch --exit-code > repo.patch || echo "patch_created=true" >> $GITHUB_OUTPUT + shell: bash + - name: Upload patch + if: steps.create_patch.outputs.patch_created + uses: actions/upload-artifact@v7 + with: + name: repo.patch + path: repo.patch + overwrite: true + pr: + name: Create Pull Request + needs: upgrade + runs-on: ubuntu-latest + permissions: + contents: read + if: \${{ needs.upgrade.outputs.patch_created }} + steps: + - name: Generate token + id: generate_token + uses: actions/create-github-app-token@v2 + with: + app-id: \${{ secrets.PROJEN_APP_ID }} + private-key: \${{ secrets.PROJEN_APP_PRIVATE_KEY }} + - name: Checkout + uses: actions/checkout@v6 + - name: Download patch + uses: actions/download-artifact@v8 + with: + name: repo.patch + path: \${{ runner.temp }} + - name: Apply patch + run: '[ -s \${{ runner.temp }}/repo.patch ] && git apply \${{ runner.temp }}/repo.patch || echo "Empty patch. Skipping."' + - name: Set git identity + run: |- + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Create Pull Request + uses: peter-evans/create-pull-request@v8 + with: + token: \${{ steps.generate_token.outputs.token }} + commit-message: "chore(deps): pin github actions to latest release SHAs" + branch: github-actions/update-actions + title: "chore(deps): update pinned GitHub Actions" + labels: auto-approve,dependencies,github-actions + body: |- + Pins action references to the latest stable release commit SHAs. + + See the job summary of the [workflow run] for a per-action diff. + + [Workflow Run]: \${{ github.server_url }}/\${{ github.repository }}/actions/runs/\${{ github.run_id }} + + ------ + + *Automatically created by the \`update-actions\` workflow.* + author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + signoff: true +" +`; diff --git a/test/update-actions-workflow.test.ts b/test/update-actions-workflow.test.ts new file mode 100644 index 0000000..f8def44 --- /dev/null +++ b/test/update-actions-workflow.test.ts @@ -0,0 +1,164 @@ +import { AwsCdkTypeScriptApp } from 'projen/lib/awscdk'; +import { synthSnapshot } from 'projen/lib/util/synth'; +import { UpdateActionsWorkflow } from '../src/security'; + +function newApp() { + return new AwsCdkTypeScriptApp({ + cdkVersion: '2.102.0', + defaultReleaseBranch: 'main', + name: 'test-project', + }); +} + +describe('UpdateActionsWorkflow', () => { + test('registers an update-actions workflow on the project', () => { + const app = newApp(); + new UpdateActionsWorkflow(app); + + const workflow = app.github!.tryFindWorkflow('update-actions'); + expect(workflow).toBeDefined(); + }); + + test('matches snapshot with defaults', () => { + const app = newApp(); + new UpdateActionsWorkflow(app); + + const snapshot = synthSnapshot(app); + expect(snapshot['.github/workflows/update-actions.yml']).toMatchSnapshot(); + }); + + test('default paths target projen-managed files only', () => { + const app = newApp(); + new UpdateActionsWorkflow(app); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + expect(yaml).toContain('npx update-github-actions .projen .projenrc.ts'); + expect(yaml).not.toMatch(/npx update-github-actions .*\bsrc\b/); + }); + + test('custom paths are forwarded to the script invocation', () => { + const app = newApp(); + new UpdateActionsWorkflow(app, { + paths: ['src', '.projen', '.projenrc.ts', 'custom/workflows'], + }); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + expect(yaml).toContain('npx update-github-actions src .projen .projenrc.ts custom/workflows'); + }); + + test('custom command replaces the default bin invocation', () => { + const app = newApp(); + new UpdateActionsWorkflow(app, { + command: 'npx tsx src/security/update-github-actions.ts', + }); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + expect(yaml).toContain('npx tsx src/security/update-github-actions.ts .projen .projenrc.ts'); + expect(yaml).not.toContain('npx update-github-actions'); + }); + + test('custom schedule sets the cron expression', () => { + const app = newApp(); + new UpdateActionsWorkflow(app, { schedule: '30 3 * * *' }); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + expect(yaml).toContain('cron: 30 3 * * *'); + }); + + test('custom runner tags are applied to both jobs', () => { + const app = newApp(); + new UpdateActionsWorkflow(app, { runnerTags: ['self-hosted', 'linux'] }); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + const runnerBlocks = yaml.match(/runs-on:\n(?:\s+- [^\n]+\n)+/g) ?? []; + expect(runnerBlocks).toHaveLength(2); + runnerBlocks.forEach((block: string) => { + expect(block).toContain('- self-hosted'); + expect(block).toContain('- linux'); + }); + }); + + test('custom labels and branch flow into the PR step', () => { + const app = newApp(); + new UpdateActionsWorkflow(app, { + labels: ['automated', 'deps'], + branch: 'chore/pin-actions', + }); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + expect(yaml).toContain('labels: automated,deps'); + expect(yaml).toContain('branch: chore/pin-actions'); + }); + + test('allowPrerelease propagates to the step env', () => { + const app = newApp(); + new UpdateActionsWorkflow(app, { allowPrerelease: true }); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + expect(yaml).toContain('ALLOW_PRERELEASE: "true"'); + }); + + test('omits ALLOW_PRERELEASE by default', () => { + const app = newApp(); + new UpdateActionsWorkflow(app); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + expect(yaml).not.toContain('ALLOW_PRERELEASE'); + }); + + test('uses the GitHub App token by default', () => { + const app = newApp(); + new UpdateActionsWorkflow(app); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + expect(yaml).toContain('actions/create-github-app-token'); + expect(yaml).toContain('app-id: ${{ secrets.PROJEN_APP_ID }}'); + expect(yaml).toContain('private-key: ${{ secrets.PROJEN_APP_PRIVATE_KEY }}'); + expect(yaml).toContain('token: ${{ steps.generate_token.outputs.token }}'); + }); + + test('overriding secret names propagates to the token step', () => { + const app = newApp(); + new UpdateActionsWorkflow(app, { + tokenAppIdSecret: 'MY_APP_ID', + tokenAppPrivateKeySecret: 'MY_APP_KEY', + }); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + expect(yaml).toContain('app-id: ${{ secrets.MY_APP_ID }}'); + expect(yaml).toContain('private-key: ${{ secrets.MY_APP_KEY }}'); + }); + + test('falls back to GITHUB_TOKEN when app-token secrets are empty', () => { + const app = newApp(); + new UpdateActionsWorkflow(app, { + tokenAppIdSecret: '', + tokenAppPrivateKeySecret: '', + }); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + expect(yaml).not.toContain('actions/create-github-app-token'); + expect(yaml).not.toContain('generate_token'); + expect(yaml).toContain('token: ${{ secrets.GITHUB_TOKEN }}'); + }); + + test('upgrade job exposes patch_created output', () => { + const app = newApp(); + new UpdateActionsWorkflow(app); + + const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; + expect(yaml).toContain('patch_created: ${{ steps.create_patch.outputs.patch_created }}'); + expect(yaml).toContain('if: ${{ needs.upgrade.outputs.patch_created }}'); + }); + + test('throws when the project has no GitHub integration', () => { + const app = new AwsCdkTypeScriptApp({ + cdkVersion: '2.102.0', + defaultReleaseBranch: 'main', + name: 'test-project', + github: false, + }); + + expect(() => new UpdateActionsWorkflow(app)).toThrow(/GitHubProject/); + }); +}); From e3762206174d41e11aad167e989bca225306a8bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 14:29:57 +0000 Subject: [PATCH 09/11] test: add unit tests for update-github-actions script Refactors the script's core logic into testable @internal functions (isScannable, walk, collect, repoRoot, rewriteContent, renderSummary) gated behind `require.main === module` for the CLI entry point. The @internal JSDoc tag keeps them off the public jsii API surface. Adds 16 tests covering extension filtering, directory walking with mixed file/dir inputs, sub-path action resolution, standalone vs inline rewrite behavior, comment refresh, no-op on matching SHA, unresolved references, and the markdown summary format. https://claude.ai/code/session_01Avf49PnGcNWmgpViU3uzyE --- src/security/update-github-actions.ts | 191 ++++++++++++++---------- test/update-github-actions.test.ts | 202 ++++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 79 deletions(-) create mode 100644 test/update-github-actions.test.ts diff --git a/src/security/update-github-actions.ts b/src/security/update-github-actions.ts index bfa77e5..b31eb23 100644 --- a/src/security/update-github-actions.ts +++ b/src/security/update-github-actions.ts @@ -10,12 +10,14 @@ import { execSync } from 'child_process'; import { readdirSync, readFileSync, statSync, writeFileSync } from 'fs'; import { join } from 'path'; -interface ResolvedAction { +/** @internal */ +export interface ResolvedAction { readonly tag: string; readonly sha: string; } -interface Change { +/** @internal */ +export interface Change { readonly file: string; readonly repo: string; readonly from: string; @@ -23,6 +25,15 @@ interface Change { readonly tag: string; } +/** @internal */ +export interface RewriteResult { + readonly updated: string; + readonly changes: Change[]; +} + +/** @internal */ +export type Resolver = (repo: string) => ResolvedAction | null; + interface GitObject { readonly type: 'tag' | 'commit'; readonly sha: string; @@ -44,29 +55,26 @@ interface Tag { readonly name: string; } -const PATHS = process.argv.slice(2); -if (PATHS.length === 0) { - console.error('usage: update-github-actions [ ...]'); - process.exit(2); -} -const ALLOW_PRERELEASE = process.env.ALLOW_PRERELEASE === 'true'; - // Matches any `uses: 'owner/repo@ref'` literal, including inline forms like // `{ name: 'Checkout', uses: 'actions/checkout@v6' },`. Group 1 captures the // `uses:'` prefix, 2 the owner/repo(/sub), 3 the ref, 4 the closing quote. -const LITERAL_RE = /(uses:\s*')([A-Za-z0-9_.\-/]+)@([A-Za-z0-9_.\-]+)(')/g; +/** @internal */ +export const LITERAL_RE = /(uses:\s*')([A-Za-z0-9_.\-/]+)@([A-Za-z0-9_.\-]+)(')/g; // Matches a line whose only non-whitespace content is a `uses:` literal (plus // optional trailing comma and optional existing `// tag` comment). Group 1 // captures everything up to and including the closing quote + comma; group 2 // captures any existing trailing comment, which we replace. -const STANDALONE_LINE_RE = /^(\s*uses:\s*'[A-Za-z0-9_.\-/]+@[A-Za-z0-9_.\-]+'\s*,?)(\s*\/\/[^\n]*)?$/gm; +/** @internal */ +export const STANDALONE_LINE_RE = /^(\s*uses:\s*'[A-Za-z0-9_.\-/]+@[A-Za-z0-9_.\-]+'\s*,?)(\s*\/\/[^\n]*)?$/gm; -function isScannable(p: string): boolean { +/** @internal */ +export function isScannable(p: string): boolean { return p.endsWith('.ts') || p.endsWith('.json') || p.endsWith('.yml') || p.endsWith('.yaml'); } -function walk(dir: string): string[] { +/** @internal */ +export function walk(dir: string): string[] { const out: string[] = []; for (const entry of readdirSync(dir)) { const p = join(dir, entry); @@ -77,7 +85,8 @@ function walk(dir: string): string[] { return out; } -function collect(paths: string[]): string[] { +/** @internal */ +export function collect(paths: string[]): string[] { const out: string[] = []; for (const p of paths) { const s = statSync(p); @@ -87,20 +96,62 @@ function collect(paths: string[]): string[] { return out; } -function gh(path: string): T { - const raw = execSync(`gh api ${path}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - return JSON.parse(raw) as T; -} - -function repoRoot(useRef: string): string { +/** @internal */ +export function repoRoot(useRef: string): string { // Sub-path actions like `github/codeql-action/init` must be resolved against // the root repo. Take only the first two path segments. const [owner, repo] = useRef.split('/'); return `${owner}/${repo}`; } -function latestStableTag(repo: string): string { - if (ALLOW_PRERELEASE) { +/** + * Rewrites a file's content, swapping each resolved ref with its SHA and + * adding/refreshing a trailing `// ` comment on lines whose only + * non-whitespace content is a `uses:` literal. Inline occurrences are + * SHA-swapped only — their comment state is left untouched so surrounding + * properties stay intact. + * @internal + */ +export function rewriteContent(file: string, original: string, resolve: Resolver): RewriteResult { + const tagByRepo = new Map(); + const changes: Change[] = []; + + let updated = original.replace(LITERAL_RE, (match, pre, repo, ref, quote) => { + const target = resolve(repo); + if (!target || ref === target.sha) return match; + changes.push({ file, repo, from: ref, to: target.sha, tag: target.tag }); + tagByRepo.set(repo, target.tag); + return `${pre}${repo}@${target.sha}${quote}`; + }); + + updated = updated.replace(STANDALONE_LINE_RE, (line, head) => { + const m = /'([A-Za-z0-9_.\-/]+)@[A-Za-z0-9_.\-]+'/.exec(head); + if (!m) return line; + const resolved = resolve(m[1]); + const tag = tagByRepo.get(m[1]) ?? resolved?.tag; + if (!tag) return line; + return `${head.trimEnd()} // ${tag}`; + }); + + return { updated, changes }; +} + +/** @internal */ +export function renderSummary(changes: Change[]): string { + const lines = ['## Action updates', '', '| Action | From | To (SHA) | Tag |', '| --- | --- | --- | --- |']; + for (const c of changes) { + lines.push(`| \`${c.repo}\` | \`${c.from}\` | \`${c.to}\` | \`${c.tag}\` |`); + } + return lines.join('\n') + '\n'; +} + +function gh(path: string): T { + const raw = execSync(`gh api ${path}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + return JSON.parse(raw) as T; +} + +function latestStableTag(repo: string, allowPrerelease: boolean): string { + if (allowPrerelease) { const releases = gh(`/repos/${repo}/releases?per_page=10`); if (Array.isArray(releases) && releases.length > 0) return releases[0].tag_name; } else { @@ -120,7 +171,6 @@ function latestStableTag(repo: string): string { function resolveSha(repo: string, tag: string): string { const ref = gh(`/repos/${repo}/git/ref/tags/${encodeURIComponent(tag)}`); let object: GitObject = ref.object; - // Annotated tags point to a tag object; follow through to the commit. while (object.type === 'tag') { const t = gh(`/repos/${repo}/git/tags/${object.sha}`); object = t.object; @@ -129,65 +179,48 @@ function resolveSha(repo: string, tag: string): string { return object.sha; } -const files = collect(PATHS); -const seen = new Map(); -const changes: Change[] = []; - -function resolve(repoPath: string): ResolvedAction | null { - const root = repoRoot(repoPath); - const cached = seen.get(root); - if (cached !== undefined) return cached; - try { - const tag = latestStableTag(root); - const sha = resolveSha(root, tag); - const resolved: ResolvedAction = { tag, sha }; - seen.set(root, resolved); - console.error(`resolved ${root} ${tag} -> ${sha}`); - return resolved; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error(`skip ${root}: ${message}`); - seen.set(root, null); - return null; +function main(): void { + const paths = process.argv.slice(2); + if (paths.length === 0) { + console.error('usage: update-github-actions [ ...]'); + process.exit(2); } -} + const allowPrerelease = process.env.ALLOW_PRERELEASE === 'true'; + const cache = new Map(); -for (const file of files) { - const original = readFileSync(file, 'utf8'); - const tagByRepo = new Map(); - - // Pass 1: replace the ref with the resolved SHA in every `uses:` literal, - // including inline occurrences that share their line with other properties. - let updated = original.replace(LITERAL_RE, (match, pre, repo, ref, quote) => { - const target = resolve(repo); - if (!target || ref === target.sha) return match; - changes.push({ file, repo, from: ref, to: target.sha, tag: target.tag }); - tagByRepo.set(repo, target.tag); - return `${pre}${repo}@${target.sha}${quote}`; - }); - - // Pass 2: on lines whose only content is a single `uses:` literal, insert or - // refresh a trailing `// ` comment so reviewers see the human-readable - // version alongside the SHA. Inline forms are skipped to avoid corrupting - // surrounding properties. - updated = updated.replace(STANDALONE_LINE_RE, (line, head) => { - const m = /'([A-Za-z0-9_.\-/]+)@[A-Za-z0-9_.\-]+'/.exec(head); - if (!m) return line; - const tag = tagByRepo.get(m[1]) ?? seen.get(repoRoot(m[1]))?.tag; - if (!tag) return line; - return `${head.trimEnd()} // ${tag}`; - }); - - if (updated !== original) writeFileSync(file, updated); -} + const resolve: Resolver = (repoPath: string): ResolvedAction | null => { + const root = repoRoot(repoPath); + const cached = cache.get(root); + if (cached !== undefined) return cached; + try { + const tag = latestStableTag(root, allowPrerelease); + const sha = resolveSha(root, tag); + const resolved: ResolvedAction = { tag, sha }; + cache.set(root, resolved); + console.error(`resolved ${root} ${tag} -> ${sha}`); + return resolved; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`skip ${root}: ${message}`); + cache.set(root, null); + return null; + } + }; + + const allChanges: Change[] = []; + for (const file of collect(paths)) { + const original = readFileSync(file, 'utf8'); + const { updated, changes } = rewriteContent(file, original, resolve); + allChanges.push(...changes); + if (updated !== original) writeFileSync(file, updated); + } -const summaryPath = process.env.GITHUB_STEP_SUMMARY; -if (summaryPath && changes.length > 0) { - const lines = ['## Action updates', '', '| Action | From | To (SHA) | Tag |', '| --- | --- | --- | --- |']; - for (const c of changes) { - lines.push(`| \`${c.repo}\` | \`${c.from}\` | \`${c.to}\` | \`${c.tag}\` |`); + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (summaryPath && allChanges.length > 0) { + writeFileSync(summaryPath, renderSummary(allChanges), { flag: 'a' }); } - writeFileSync(summaryPath, lines.join('\n') + '\n', { flag: 'a' }); + + console.log(JSON.stringify({ changes: allChanges, resolved: [...cache.entries()] }, null, 2)); } -console.log(JSON.stringify({ changes, resolved: [...seen.entries()] }, null, 2)); +if (require.main === module) main(); diff --git a/test/update-github-actions.test.ts b/test/update-github-actions.test.ts new file mode 100644 index 0000000..7d0f728 --- /dev/null +++ b/test/update-github-actions.test.ts @@ -0,0 +1,202 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + collect, + isScannable, + renderSummary, + repoRoot, + rewriteContent, + walk, + type Change, + type ResolvedAction, + type Resolver, +} from '../src/security/update-github-actions'; + +function fixedResolver(table: Record): Resolver { + // Mirror the production resolver's contract: normalize sub-path actions + // (`github/codeql-action/init` → `github/codeql-action`) before lookup. + return (repo: string) => { + const key = repoRoot(repo); + return key in table ? table[key] : null; + }; +} + +describe('isScannable', () => { + test('accepts ts/json/yml/yaml', () => { + expect(isScannable('a.ts')).toBe(true); + expect(isScannable('a.json')).toBe(true); + expect(isScannable('a.yml')).toBe(true); + expect(isScannable('a.yaml')).toBe(true); + }); + + test('rejects other extensions', () => { + expect(isScannable('a.md')).toBe(false); + expect(isScannable('a.js')).toBe(false); + expect(isScannable('README')).toBe(false); + }); +}); + +describe('repoRoot', () => { + test('returns owner/repo for simple refs', () => { + expect(repoRoot('actions/checkout')).toBe('actions/checkout'); + }); + + test('strips sub-paths for nested actions', () => { + expect(repoRoot('github/codeql-action/init')).toBe('github/codeql-action'); + expect(repoRoot('github/codeql-action/analyze')).toBe('github/codeql-action'); + }); +}); + +describe('walk / collect', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'uga-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test('walks recursively, filtered by scannable extensions', () => { + mkdirSync(join(dir, 'nested')); + writeFileSync(join(dir, 'a.ts'), ''); + writeFileSync(join(dir, 'b.md'), ''); + writeFileSync(join(dir, 'nested', 'c.yml'), ''); + writeFileSync(join(dir, 'nested', 'd.txt'), ''); + + const found = walk(dir).sort(); + expect(found).toEqual([join(dir, 'a.ts'), join(dir, 'nested', 'c.yml')]); + }); + + test('collect mixes files and directories', () => { + mkdirSync(join(dir, 'sub')); + writeFileSync(join(dir, 'top.ts'), ''); + writeFileSync(join(dir, 'sub', 'inner.json'), ''); + + const direct = join(dir, 'standalone.yaml'); + writeFileSync(direct, ''); + + const result = collect([join(dir, 'top.ts'), join(dir, 'sub'), direct]).sort(); + expect(result).toEqual([direct, join(dir, 'sub', 'inner.json'), join(dir, 'top.ts')].sort()); + }); + + test('collect skips explicit files with non-scannable extensions', () => { + const readme = join(dir, 'README.md'); + writeFileSync(readme, ''); + expect(collect([readme])).toEqual([]); + }); +}); + +describe('rewriteContent', () => { + const SHA = 'deadbeefcafebabe0123456789abcdef01234567'; + const resolver = fixedResolver({ + 'actions/checkout': { tag: 'v6.1.0', sha: SHA }, + 'actions/download-artifact': { tag: 'v8.0.0', sha: `${SHA}08` }, + 'aws-actions/configure-aws-credentials': { tag: 'v5.2.0', sha: `${SHA}aw` }, + }); + + test('rewrites a standalone line and appends a tag comment', () => { + const input = ` { name: 'Checkout' }, + { + uses: 'actions/checkout@v6', + },`; + const { updated, changes } = rewriteContent('file.ts', input, resolver); + + expect(updated).toContain(`uses: 'actions/checkout@${SHA}', // v6.1.0`); + expect(changes).toEqual([ + { file: 'file.ts', repo: 'actions/checkout', from: 'v6', to: SHA, tag: 'v6.1.0' }, + ]); + }); + + test('refreshes an existing trailing comment to the new tag', () => { + const input = ' uses: \'actions/checkout@oldsha\', // v5.0.0\n'; + const { updated } = rewriteContent('file.ts', input, resolver); + expect(updated).toContain(`uses: 'actions/checkout@${SHA}', // v6.1.0`); + expect(updated).not.toContain('v5.0.0'); + }); + + test('rewrites inline uses without corrupting surrounding properties', () => { + const input = ' { name: \'Checkout\', uses: \'actions/checkout@v6\' },\n'; + const { updated } = rewriteContent('file.ts', input, resolver); + expect(updated).toBe( + ` { name: 'Checkout', uses: 'actions/checkout@${SHA}' },\n`, + ); + // Inline form must not get a trailing `// tag` comment appended. + expect(updated).not.toContain('//'); + }); + + test('leaves unresolved references untouched', () => { + const input = ' uses: \'some-org/unknown@v1\',\n'; + const { updated, changes } = rewriteContent('file.ts', input, resolver); + expect(updated).toBe(input); + expect(changes).toEqual([]); + }); + + test('is a no-op when the ref already matches the SHA', () => { + const input = ` uses: 'actions/checkout@${SHA}', // v6.1.0\n`; + const { updated, changes } = rewriteContent('file.ts', input, resolver); + expect(updated).toBe(input); + expect(changes).toEqual([]); + }); + + test('handles multiple distinct actions and mixed forms in one file', () => { + const input = [ + " { name: 'Checkout', uses: 'actions/checkout@v6' },", + ' {', + " uses: 'actions/download-artifact@v7',", + ' },', + " { uses: 'aws-actions/configure-aws-credentials@v5', with: { role: 'x' } },", + '', + ].join('\n'); + + const { updated, changes } = rewriteContent('f.ts', input, resolver); + + expect(updated).toContain(`{ name: 'Checkout', uses: 'actions/checkout@${SHA}' }`); + expect(updated).toContain(`uses: 'actions/download-artifact@${SHA}08', // v8.0.0`); + expect(updated).toContain( + `{ uses: 'aws-actions/configure-aws-credentials@${SHA}aw', with: { role: 'x' } }`, + ); + expect(changes.map((c) => c.repo).sort()).toEqual([ + 'actions/checkout', + 'actions/download-artifact', + 'aws-actions/configure-aws-credentials', + ]); + }); + + test('resolves sub-path actions against the root repo', () => { + const nested = fixedResolver({ + 'github/codeql-action': { tag: 'v3.29.0', sha: 'feedface0123456789abcdef0123456789abcdef' }, + }); + const input = ' uses: \'github/codeql-action/init@v3\',\n'; + + const { updated, changes } = rewriteContent('f.ts', input, nested); + expect(updated).toContain( + 'uses: \'github/codeql-action/init@feedface0123456789abcdef0123456789abcdef\', // v3.29.0', + ); + expect(changes[0].repo).toBe('github/codeql-action/init'); + }); + + test('skips standalone comment refresh when the resolver returns null', () => { + const input = ' uses: \'some-org/unknown@v1\',\n'; + const unresolvable = fixedResolver({}); + const { updated } = rewriteContent('f.ts', input, unresolvable); + expect(updated).toBe(input); + }); +}); + +describe('renderSummary', () => { + test('emits a markdown table with one row per change', () => { + const changes: Change[] = [ + { file: 'a.ts', repo: 'actions/checkout', from: 'v6', to: 'abc', tag: 'v6.1.0' }, + { file: 'b.ts', repo: 'aws-actions/configure-aws-credentials', from: 'v5', to: 'def', tag: 'v5.2.0' }, + ]; + const out = renderSummary(changes); + expect(out).toContain('## Action updates'); + expect(out).toContain('| Action | From | To (SHA) | Tag |'); + expect(out).toContain('| `actions/checkout` | `v6` | `abc` | `v6.1.0` |'); + expect(out).toContain('| `aws-actions/configure-aws-credentials` | `v5` | `def` | `v5.2.0` |'); + expect(out.endsWith('\n')).toBe(true); + }); +}); From c36d0526a7a5717d7fd87f3acdd18af9f8141de4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 14:42:10 +0000 Subject: [PATCH 10/11] feat: scan JavaScript projen configs too Adds `.js`, `.cjs`, and `.mjs` to the scannable extensions so projects using `.projenrc.js` benefit from the same pinning maintenance. The default scan paths now include both `.projenrc.ts` and `.projenrc.js`; `collect` silently skips non-existent paths so either variant works without configuration. https://claude.ai/code/session_01Avf49PnGcNWmgpViU3uzyE --- README.md | 15 +++++----- src/security/update-actions-workflow.ts | 16 ++++++----- src/security/update-github-actions.ts | 7 +++-- .../update-actions-workflow.test.ts.snap | 2 +- test/update-actions-workflow.test.ts | 8 ++++-- test/update-github-actions.test.ts | 28 +++++++++++++++++-- 6 files changed, 54 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index a34ebc5..7f92a16 100644 --- a/README.md +++ b/README.md @@ -627,13 +627,14 @@ new UpdateActionsWorkflow(project); Options cover the cron schedule, the paths to scan, the PR branch and labels, and the names of the GitHub App secrets used to open the pull request. The default scans only -projen-managed files (`.projen/` and `.projenrc.ts`), which is the right scope for most -consumers — their action references live in the projen configuration, not in hand-authored -source. Projects that embed action strings directly in their library code (such as -`projen-pipelines` itself) should extend the list with `paths: ['src', '.projen', '.projenrc.ts']`. -Without overrides the workflow runs weekly and opens a PR via the -`PROJEN_APP_ID` / `PROJEN_APP_PRIVATE_KEY` app token. Pass `tokenAppIdSecret: ''` to fall back -to the default `GITHUB_TOKEN`. +projen-managed files (`.projen/`, `.projenrc.ts`, `.projenrc.js`), which is the right scope +for most consumers — their action references live in the projen configuration, not in +hand-authored source. Missing paths are silently skipped, so the TypeScript and JavaScript +projenrc variants can both be listed by default. Projects that embed action strings directly +in their library code (such as `projen-pipelines` itself) should extend the list with +`paths: ['src', '.projen', '.projenrc.ts']`. Without overrides the workflow runs weekly and +opens a PR via the `PROJEN_APP_ID` / `PROJEN_APP_PRIVATE_KEY` app token. Pass +`tokenAppIdSecret: ''` to fall back to the default `GITHUB_TOKEN`. ### Reviewing PRs produced by `update-actions` diff --git a/src/security/update-actions-workflow.ts b/src/security/update-actions-workflow.ts index 62db48c..9d54e88 100644 --- a/src/security/update-actions-workflow.ts +++ b/src/security/update-actions-workflow.ts @@ -9,16 +9,18 @@ export interface UpdateActionsWorkflowOptions { /** * File or directory paths to scan for `uses: 'owner/repo@ref'` literals. * - * Directories are walked recursively for `.ts`, `.json`, `.yml`, and `.yaml` - * files; individual files are scanned directly. + * Directories are walked recursively for `.ts`, `.js`, `.cjs`, `.mjs`, + * `.json`, `.yml`, and `.yaml` files; individual files are scanned directly. + * Non-existent paths are silently skipped so the default can cover both + * TypeScript and JavaScript projen configurations. * * The default targets projen-managed files only, which is the right scope - * for downstream consumers: their action references live in `.projenrc.ts` - * and `.projen/` rather than in their application source. Projects that - * embed action strings in hand-authored source (such as `projen-pipelines` + * for downstream consumers: their action references live in the projen + * configuration rather than in application source. Projects that embed + * action strings in hand-authored source (such as `projen-pipelines` * itself) should extend this list with `'src'`. * - * @default ['.projen', '.projenrc.ts'] + * @default ['.projen', '.projenrc.ts', '.projenrc.js'] */ readonly paths?: string[]; @@ -111,7 +113,7 @@ export class UpdateActionsWorkflow extends Component { throw new Error('UpdateActionsWorkflow requires a GitHubProject with github integration enabled.'); } - const paths = options.paths ?? ['.projen', '.projenrc.ts']; + const paths = options.paths ?? ['.projen', '.projenrc.ts', '.projenrc.js']; const schedule = options.schedule ?? '0 6 * * 1'; const runsOn = options.runnerTags ?? ['ubuntu-latest']; const labels = options.labels ?? ['auto-approve', 'dependencies', 'github-actions']; diff --git a/src/security/update-github-actions.ts b/src/security/update-github-actions.ts index b31eb23..5dc4c41 100644 --- a/src/security/update-github-actions.ts +++ b/src/security/update-github-actions.ts @@ -7,7 +7,7 @@ // Relies on the `gh` CLI for authenticated GitHub API access (GH_TOKEN env). import { execSync } from 'child_process'; -import { readdirSync, readFileSync, statSync, writeFileSync } from 'fs'; +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs'; import { join } from 'path'; /** @internal */ @@ -68,9 +68,11 @@ export const LITERAL_RE = /(uses:\s*')([A-Za-z0-9_.\-/]+)@([A-Za-z0-9_.\-]+)(')/ /** @internal */ export const STANDALONE_LINE_RE = /^(\s*uses:\s*'[A-Za-z0-9_.\-/]+@[A-Za-z0-9_.\-]+'\s*,?)(\s*\/\/[^\n]*)?$/gm; +const SCANNABLE_EXTENSIONS = ['.ts', '.js', '.cjs', '.mjs', '.json', '.yml', '.yaml']; + /** @internal */ export function isScannable(p: string): boolean { - return p.endsWith('.ts') || p.endsWith('.json') || p.endsWith('.yml') || p.endsWith('.yaml'); + return SCANNABLE_EXTENSIONS.some((ext) => p.endsWith(ext)); } /** @internal */ @@ -89,6 +91,7 @@ export function walk(dir: string): string[] { export function collect(paths: string[]): string[] { const out: string[] = []; for (const p of paths) { + if (!existsSync(p)) continue; const s = statSync(p); if (s.isDirectory()) out.push(...walk(p)); else if (isScannable(p)) out.push(p); diff --git a/test/__snapshots__/update-actions-workflow.test.ts.snap b/test/__snapshots__/update-actions-workflow.test.ts.snap index 4ceac3d..09f97d1 100644 --- a/test/__snapshots__/update-actions-workflow.test.ts.snap +++ b/test/__snapshots__/update-actions-workflow.test.ts.snap @@ -28,7 +28,7 @@ jobs: - name: Pin actions to latest release SHAs env: GH_TOKEN: \${{ secrets.GITHUB_TOKEN }} - run: npx update-github-actions .projen .projenrc.ts + run: npx update-github-actions .projen .projenrc.ts .projenrc.js - name: Regenerate project run: npx projen build - name: Find mutations diff --git a/test/update-actions-workflow.test.ts b/test/update-actions-workflow.test.ts index f8def44..909cbbe 100644 --- a/test/update-actions-workflow.test.ts +++ b/test/update-actions-workflow.test.ts @@ -27,12 +27,12 @@ describe('UpdateActionsWorkflow', () => { expect(snapshot['.github/workflows/update-actions.yml']).toMatchSnapshot(); }); - test('default paths target projen-managed files only', () => { + test('default paths target projen-managed files (TS + JS) only', () => { const app = newApp(); new UpdateActionsWorkflow(app); const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; - expect(yaml).toContain('npx update-github-actions .projen .projenrc.ts'); + expect(yaml).toContain('npx update-github-actions .projen .projenrc.ts .projenrc.js'); expect(yaml).not.toMatch(/npx update-github-actions .*\bsrc\b/); }); @@ -53,7 +53,9 @@ describe('UpdateActionsWorkflow', () => { }); const yaml = synthSnapshot(app)['.github/workflows/update-actions.yml']; - expect(yaml).toContain('npx tsx src/security/update-github-actions.ts .projen .projenrc.ts'); + expect(yaml).toContain( + 'npx tsx src/security/update-github-actions.ts .projen .projenrc.ts .projenrc.js', + ); expect(yaml).not.toContain('npx update-github-actions'); }); diff --git a/test/update-github-actions.test.ts b/test/update-github-actions.test.ts index 7d0f728..b98ea7b 100644 --- a/test/update-github-actions.test.ts +++ b/test/update-github-actions.test.ts @@ -23,8 +23,11 @@ function fixedResolver(table: Record): Resolver { } describe('isScannable', () => { - test('accepts ts/json/yml/yaml', () => { + test('accepts ts/js/cjs/mjs/json/yml/yaml', () => { expect(isScannable('a.ts')).toBe(true); + expect(isScannable('a.js')).toBe(true); + expect(isScannable('a.cjs')).toBe(true); + expect(isScannable('a.mjs')).toBe(true); expect(isScannable('a.json')).toBe(true); expect(isScannable('a.yml')).toBe(true); expect(isScannable('a.yaml')).toBe(true); @@ -32,7 +35,7 @@ describe('isScannable', () => { test('rejects other extensions', () => { expect(isScannable('a.md')).toBe(false); - expect(isScannable('a.js')).toBe(false); + expect(isScannable('a.py')).toBe(false); expect(isScannable('README')).toBe(false); }); }); @@ -87,6 +90,27 @@ describe('walk / collect', () => { writeFileSync(readme, ''); expect(collect([readme])).toEqual([]); }); + + test('collect silently skips non-existent paths', () => { + writeFileSync(join(dir, 'exists.ts'), ''); + const result = collect([ + join(dir, 'exists.ts'), + join(dir, 'does-not-exist.ts'), + join(dir, 'also-missing'), + ]); + expect(result).toEqual([join(dir, 'exists.ts')]); + }); + + test('walk picks up js/cjs/mjs files', () => { + writeFileSync(join(dir, 'a.js'), ''); + writeFileSync(join(dir, 'b.cjs'), ''); + writeFileSync(join(dir, 'c.mjs'), ''); + expect(walk(dir).sort()).toEqual([ + join(dir, 'a.js'), + join(dir, 'b.cjs'), + join(dir, 'c.mjs'), + ].sort()); + }); }); describe('rewriteContent', () => { From 13cb81fbc3f426adee1fa4d281f640505aabbffe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:43:25 +0000 Subject: [PATCH 11/11] chore: self mutation Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- API.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/API.md b/API.md index a4bb36d..bd4bf0b 100644 --- a/API.md +++ b/API.md @@ -6668,17 +6668,19 @@ public readonly paths: string[]; ``` - *Type:* string[] -- *Default:* ['.projen', '.projenrc.ts'] +- *Default:* ['.projen', '.projenrc.ts', '.projenrc.js'] File or directory paths to scan for `uses: 'owner/repo@ref'` literals. -Directories are walked recursively for `.ts`, `.json`, `.yml`, and `.yaml` -files; individual files are scanned directly. +Directories are walked recursively for `.ts`, `.js`, `.cjs`, `.mjs`, +`.json`, `.yml`, and `.yaml` files; individual files are scanned directly. +Non-existent paths are silently skipped so the default can cover both +TypeScript and JavaScript projen configurations. The default targets projen-managed files only, which is the right scope -for downstream consumers: their action references live in `.projenrc.ts` -and `.projen/` rather than in their application source. Projects that -embed action strings in hand-authored source (such as `projen-pipelines` +for downstream consumers: their action references live in the projen +configuration rather than in application source. Projects that embed +action strings in hand-authored source (such as `projen-pipelines` itself) should extend this list with `'src'`. ---