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..3372514
--- /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: 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
+ 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/.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/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/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/.projen/tasks.json b/.projen/tasks.json
index ce32d52..1cc3c1a 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 eb678b3..afc9c03 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',
@@ -20,6 +21,7 @@ const project = new cdk.JsiiProject({
'constructs',
'fs-extra',
'@types/fs-extra',
+ 'tsx',
],
deps: [
'commit-and-tag-version',
@@ -51,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,
@@ -135,4 +138,17 @@ 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).
+// This package self-hosts the script from source so the workflow can run
+// 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',
+});
+
project.synth();
\ No newline at end of file
diff --git a/API.md b/API.md
index 9ba28f4..bd4bf0b 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,170 @@ 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:* ['.projen', '.projenrc.ts', '.projenrc.js']
+
+File or directory paths to scan for `uses: 'owner/repo@ref'` literals.
+
+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 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'`.
+
+---
+
+##### `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 b94eb1b..7f92a16 100644
--- a/README.md
+++ b/README.md
@@ -590,6 +590,60 @@ 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/`, `.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).
+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 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. The default scans only
+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`
+
+* 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/package-lock.json b/package-lock.json
index 5640149..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",
@@ -40,6 +41,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 +640,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 +5159,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 +14037,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..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",
@@ -60,6 +61,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/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..9d54e88
--- /dev/null
+++ b/src/security/update-actions-workflow.ts
@@ -0,0 +1,244 @@
+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`, `.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 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', '.projenrc.js']
+ */
+ 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 ?? ['.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'];
+ 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 });
+ }
+}
diff --git a/src/security/update-github-actions.ts b/src/security/update-github-actions.ts
new file mode 100644
index 0000000..5dc4c41
--- /dev/null
+++ b/src/security/update-github-actions.ts
@@ -0,0 +1,229 @@
+#!/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 'child_process';
+import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs';
+import { join } from 'path';
+
+/** @internal */
+export interface ResolvedAction {
+ readonly tag: string;
+ readonly sha: string;
+}
+
+/** @internal */
+export interface Change {
+ readonly file: string;
+ readonly repo: string;
+ readonly from: string;
+ readonly to: string;
+ 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;
+}
+
+interface GitRefResponse {
+ readonly object: GitObject;
+}
+
+interface GitTagResponse {
+ readonly object: GitObject;
+}
+
+interface Release {
+ readonly tag_name: string;
+}
+
+interface Tag {
+ readonly name: string;
+}
+
+// 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.
+/** @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.
+/** @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 SCANNABLE_EXTENSIONS.some((ext) => p.endsWith(ext));
+}
+
+/** @internal */
+export function walk(dir: string): string[] {
+ const out: string[] = [];
+ for (const entry of readdirSync(dir)) {
+ const p = join(dir, entry);
+ const s = statSync(p);
+ if (s.isDirectory()) out.push(...walk(p));
+ else if (isScannable(p)) out.push(p);
+ }
+ return out;
+}
+
+/** @internal */
+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);
+ }
+ return out;
+}
+
+/** @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}`;
+}
+
+/**
+ * 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 {
+ 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: string, tag: string): string {
+ const ref = gh(`/repos/${repo}/git/ref/tags/${encodeURIComponent(tag)}`);
+ let object: GitObject = ref.object;
+ while (object.type === 'tag') {
+ const t = gh(`/repos/${repo}/git/tags/${object.sha}`);
+ object = t.object;
+ }
+ if (!object.sha) throw new Error(`Unable to resolve SHA for ${repo}@${tag}`);
+ return object.sha;
+}
+
+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();
+
+ 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 && allChanges.length > 0) {
+ writeFileSync(summaryPath, renderSummary(allChanges), { flag: 'a' });
+ }
+
+ console.log(JSON.stringify({ changes: allChanges, resolved: [...cache.entries()] }, null, 2));
+}
+
+if (require.main === module) main();
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..09f97d1
--- /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 .projenrc.js
+ - 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..909cbbe
--- /dev/null
+++ b/test/update-actions-workflow.test.ts
@@ -0,0 +1,166 @@
+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 (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 .projenrc.js');
+ 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 .projenrc.js',
+ );
+ 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/);
+ });
+});
diff --git a/test/update-github-actions.test.ts b/test/update-github-actions.test.ts
new file mode 100644
index 0000000..b98ea7b
--- /dev/null
+++ b/test/update-github-actions.test.ts
@@ -0,0 +1,226 @@
+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/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);
+ });
+
+ test('rejects other extensions', () => {
+ expect(isScannable('a.md')).toBe(false);
+ expect(isScannable('a.py')).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([]);
+ });
+
+ 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', () => {
+ 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);
+ });
+});