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