├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── __fixtures__ ├── composite.yml ├── recursive │ └── works.yml ├── reusable.yml └── workflow.yml ├── addSecurity.js ├── addSecurity.test.js ├── bin.js ├── checkAllowedRepos.js ├── checkIgnoredRepos.test.js ├── collectWorkflowFiles.js ├── collectWorkflowFiles.test.js ├── eslint.config.mjs ├── extractActions.js ├── extractActions.test.js ├── findRefOnGithub.js ├── findRefOnGithub.test.js ├── index.js ├── isSha.js ├── isSha.test.js ├── package-lock.json ├── package.json ├── replaceActions.js └── replaceActions.test.js /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | production-dependencies: 9 | dependency-type: "production" 10 | update-types: 11 | - "minor" 12 | - "patch" 13 | development-dependencies: 14 | dependency-type: "development" 15 | update-types: 16 | - "minor" 17 | - "patch" 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [18.x, 20.x, 22.x] 12 | 13 | env: 14 | CI: true 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm ci 22 | - run: npm test 23 | - run: npm run lint 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: NPM Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | with: 12 | ref: ${{ github.event.release.target_commitish }} 13 | - name: Use Node.js 20 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: 20 17 | registry-url: https://registry.npmjs.org/ 18 | - run: npm ci 19 | - run: git config --global user.name "Michael Heap" 20 | - run: git config --global user.email "m@michaelheap.com" 21 | - run: npm version ${{ github.event.release.tag_name }} 22 | - run: npm run build --if-present 23 | - run: npm test --if-present 24 | - run: npm publish 25 | env: 26 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | - run: git push 28 | env: 29 | github-token: ${{ secrets.GITHUB_TOKEN }} 30 | - run: git tag -f ${{ github.event.release.tag_name }} ${{ github.event.release.target_commitish }} 31 | env: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | - run: git push origin ${{ github.event.release.tag_name }} --force 34 | env: 35 | github-token: ${{ secrets.GITHUB_TOKEN }} 36 | 37 | - run: | 38 | TAG=$(echo "${{ github.event.release.tag_name }}" | cut -c2-) 39 | echo "version=$TAG" >> $GITHUB_OUTPUT 40 | id: tag 41 | 42 | - name: Log in to Docker Hub 43 | uses: docker/login-action@v3 44 | with: 45 | username: ${{ secrets.DOCKER_USERNAME }} 46 | password: ${{ secrets.DOCKER_PASSWORD }} 47 | 48 | - name: Set up QEMU 49 | uses: docker/setup-qemu-action@v3 50 | 51 | - name: Set up Docker Buildx 52 | uses: docker/setup-buildx-action@v3 53 | 54 | - name: Build and push 55 | uses: docker/build-push-action@v6 56 | with: 57 | platforms: linux/amd64,linux/arm64 58 | push: true 59 | tags: mheap/pin-github-action:latest, mheap/pin-github-action:${{ steps.tag.outputs.version }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Node.js runtime as the base image 2 | FROM node:20-alpine 3 | 4 | # Set the working directory in the container 5 | WORKDIR /src 6 | 7 | # Copy the package.json and package-lock.json (if available) 8 | COPY package*.json ./ 9 | 10 | # Install the dependencies 11 | RUN npm install --production 12 | 13 | # Copy the application source code 14 | COPY . . 15 | 16 | WORKDIR /workflows 17 | ENV WORKFLOWS_DIR=/workflows 18 | 19 | # Set the entry point for the container to run the CLI 20 | # Replace 'your-cli-command' with the actual command of your CLI app 21 | ENTRYPOINT ["node", "/src/bin.js"] 22 | 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2021 Michael Heap 2 | 3 | Permission is hereby granted, free 4 | of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pin-github-action 2 | 3 | This is a tool that allows you to pin your GitHub actions dependencies to a 4 | specific SHA without requiring that you update every action manually each time 5 | you want to use a newer version of an action. 6 | 7 | It achieves this by converting your workflow to use a specific commit hash, 8 | whilst adding the original value as a comment on that line. This allows us to 9 | resolve newer SHAs for that target ref automatically in the future. 10 | 11 | It converts this: 12 | 13 | ```yaml 14 | name: Commit Push 15 | on: 16 | push: 17 | branches: 18 | - master 19 | jobs: 20 | build: 21 | name: nexmo/github-actions/submodule-auto-pr@main 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@main 25 | - name: nexmo/github-actions/submodule-auto-pr 26 | uses: nexmo/github-actions/submodule-auto-pr@main 27 | ``` 28 | 29 | In to this: 30 | 31 | ```yaml 32 | name: Commit Push 33 | on: 34 | push: 35 | branches: 36 | - master 37 | jobs: 38 | build: 39 | name: nexmo/github-actions/submodule-auto-pr@main 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@db41740e12847bb616a339b75eb9414e711417df # pin@main 43 | - name: nexmo/github-actions/submodule-auto-pr 44 | uses: nexmo/github-actions/submodule-auto-pr@73549280c1c566830040d9a01fe9050dae6a3036 # pin@main 45 | ``` 46 | 47 | For more information, see [How it works](#how-it-works). 48 | 49 | ## Installation 50 | 51 | ### Nodejs 52 | 53 | ```bash 54 | npm install -g pin-github-action 55 | ``` 56 | 57 | ### Docker 58 | 59 | ```bash 60 | alias pin-github-action='docker run --rm -v $(pwd):/workflows -e GITHUB_TOKEN mheap/pin-github-action' 61 | ``` 62 | 63 | ## Usage 64 | 65 | Use on single file: 66 | 67 | ```bash 68 | pin-github-action /path/to/.github/workflows/your-name.yml 69 | ``` 70 | 71 | Use on all YAML|YML files in a single directory: 72 | 73 | ```bash 74 | pin-github-action /path/to/.github/workflows/ 75 | ``` 76 | 77 | Use on all YAML|YML files in directory tree: 78 | 79 | ```bash 80 | pin-github-action --recursive /path/to/.github/workflows/ 81 | ``` 82 | 83 | If you run the tool on a directory and want to continue processing when a single file fails, pass the `--continue-on-error` parameter: 84 | 85 | ```bash 86 | pin-github-action --continue-on-error /path/to/.github/workflows/ 87 | ``` 88 | 89 | If you use private actions (or are hitting rate limits), you'll need to provide 90 | a GitHub access token: 91 | 92 | ```bash 93 | GITHUB_TOKEN= pin-github-action /path/to/.github/workflows/your-name.yml 94 | ``` 95 | 96 | Run it as many times as you like! Each time you run the tool the exact SHA will 97 | be updated to the latest available SHA for your pinned ref. 98 | 99 | If you're having issues, run with debug logging enabled and open an issue: 100 | 101 | ```bash 102 | DEBUG="pin-github-action*" pin-github-action /path/to/.github/workflows/your-name.yml 103 | ``` 104 | 105 | You can pass multiple files at once by adding additional files as arguments: 106 | 107 | ```bash 108 | pin-github-action first.yml second.yml 109 | ``` 110 | 111 | And you can mix directories with files: 112 | 113 | ```bash 114 | pin-github-action first.yml /path/to/.github/workflows/ 115 | ``` 116 | 117 | ## Leaving Actions unpinned 118 | 119 | To leave an action unpinned, pass the `--allow` option when running `pin-github-action`. 120 | 121 | Running `pin-github-action /path/to/.github/workflows/your-name.yml --allow "actions/*"` will turn this: 122 | 123 | ```yaml 124 | jobs: 125 | build: 126 | name: nexmo/github-actions/submodule-auto-pr@main 127 | runs-on: ubuntu-latest 128 | steps: 129 | - uses: actions/checkout@main 130 | - uses: nexmo/github-actions/submodule-auto-pr@main 131 | ``` 132 | 133 | Into this (notice how `actions/checkout@main` is ignored): 134 | 135 | ```yaml 136 | jobs: 137 | build: 138 | name: nexmo/github-actions/submodule-auto-pr@main 139 | runs-on: ubuntu-latest 140 | steps: 141 | - uses: actions/checkout@main 142 | - name: nexmo/github-actions/submodule-auto-pr 143 | uses: nexmo/github-actions/submodule-auto-pr@73549280c1c566830040d9a01fe9050dae6a3036 # pin@main 144 | ``` 145 | 146 | You can pass multiple actions to allow as a comma separated list e.g. `actions/checkout,mheap/*` 147 | 148 | A quick overview of the available globbing patterns (taken from [multimatch](https://github.com/sindresorhus/multimatch), which we use to match globs): 149 | 150 | - `*` matches any number of characters, but not `/` 151 | - `?` matches a single character, but not `/` 152 | - `**` matches any number of characters, including `/`, as long as it's the only thing in a path part 153 | - `{}` allows for a comma-separated list of "or" expressions 154 | - `!` at the beginning of a pattern will negate the match 155 | 156 | Examples: 157 | 158 | - Exact match: `actions/checkout` 159 | - Partial match: `actions/*` 160 | - Negated match: `!actions/*` (will only pin `actions/*` actions) 161 | 162 | ## Enforcing that all actions are pinned 163 | 164 | You can use [ensure-sha-pinned-actions](https://github.com/zgosalvez/github-actions-ensure-sha-pinned-actions) to fail the build if any workflows contain an unpinned action. 165 | 166 | To enable this, pass the `--enforce` flag containing a workflow name to create: 167 | 168 | ```bash 169 | pin-github-action --enforce .github/workflows/security.yaml .github/workflows 170 | ``` 171 | 172 | If you specify the `--allow` flag, these actions will be added to the `allowlist` in the `ensure-sha-pinned-actions` action too. 173 | 174 | ## Customising the {ref} comment 175 | 176 | You can specify a comment containing the `{ref}` placeholder to customise the comment added. 177 | 178 | ```bash 179 | pin-github-action -c " pin@{ref}" /path/to/workflow.yaml 180 | ``` 181 | 182 | ## How it works 183 | 184 | - Load the workflow file provided 185 | - Tokenise it in to an AST 186 | - Extract all `uses` steps, skipping any `docker://` or `./local-path` actions 187 | - Loop through all `uses` steps to determine the target ref 188 | - If there's a comment in the step, remove `pin@` and use that as the target 189 | - Otherwise, fall back to the ref in the action as the default 190 | - Look up the current SHA for each repo on GitHub and update the action to use the specific hash 191 | - If needed, add a comment with the target pinned version 192 | - Write the workflow file with the new pinned version and original target version as a comment 193 | 194 | ## Contributing 195 | 196 | 1. **Run tests**: Ensure all tests pass before submitting your changes. 197 | 198 | ```bash 199 | npm install 200 | npm test 201 | ``` 202 | 203 | 2. **Build and test locally using Docker**: You can build a container and test the application locally. 204 | 205 | ```bash 206 | docker build -t pin-github-action . 207 | docker run --rm -v $(pwd):/workflows -e GITHUB_TOKEN= pin-github-action /path/to/.github/workflows/your-name.yml 208 | ``` 209 | -------------------------------------------------------------------------------- /__fixtures__/composite.yml: -------------------------------------------------------------------------------- 1 | name: Deploy app 2 | 3 | inputs: 4 | target_environment: 5 | description: The env to deploy to 6 | required: true 7 | github_token: 8 | description: Credential (token) needed to push Git tags to current repository 9 | required: true 10 | 11 | runs: 12 | using: composite 13 | steps: 14 | - name: Create GitHub deployment 15 | uses: bobheadxi/deployments@v0.6.1 16 | id: deployment 17 | with: 18 | step: start 19 | token: ${{ inputs.github_token }} 20 | env: ${{ inputs.target_environment }} 21 | -------------------------------------------------------------------------------- /__fixtures__/recursive/works.yml: -------------------------------------------------------------------------------- 1 | name: Commit Push 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | build: 8 | name: nexmo/github-actions/submodule-auto-pr@main 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@main 12 | - name: nexmo/github-actions/submodule-auto-pr 13 | uses: nexmo/github-actions/submodule-auto-pr@main -------------------------------------------------------------------------------- /__fixtures__/reusable.yml: -------------------------------------------------------------------------------- 1 | name: Call a reusable workflow 2 | on: pull_request 3 | jobs: 4 | call-workflow: 5 | uses: mheap/reusable-workflow-test/.github/workflows/add-label.yml@main -------------------------------------------------------------------------------- /__fixtures__/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Commit Push 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | build: 8 | name: nexmo/github-actions/submodule-auto-pr@main 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@main 12 | - name: nexmo/github-actions/submodule-auto-pr 13 | uses: nexmo/github-actions/submodule-auto-pr@main -------------------------------------------------------------------------------- /addSecurity.js: -------------------------------------------------------------------------------- 1 | import * as fs from "fs/promises"; 2 | let workflow = ` 3 | on: push 4 | 5 | name: Security 6 | 7 | jobs: 8 | ensure-pinned-actions: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v4 13 | - name: Ensure SHA pinned actions 14 | uses: zgosalvez/github-actions-ensure-sha-pinned-actions@v3 15 | `.trim(); 16 | 17 | export default async function (filename, allowed, log) { 18 | const debug = log.extend("add-security"); 19 | debug(`Adding security workflow to ${filename}`); 20 | // Check if the file already exists 21 | try { 22 | await fs.stat(filename); 23 | throw new Error(`File ${filename} already exists.`); 24 | } catch (err) { 25 | if (err.code === "ENOENT") { 26 | debug(`File ${filename} does not exist, creating.`); 27 | } else { 28 | throw err; 29 | } 30 | } 31 | 32 | // Append any allow-listed repositories to the workflow 33 | if (allowed.length) { 34 | debug(`Adding allow-listed repositories to the workflow: ${allowed}`); 35 | workflow += ` 36 | with: 37 | allowlist: | 38 | `.trimEnd(); 39 | allowed.forEach((repo) => { 40 | if (repo.endsWith("/*")) { 41 | repo = repo.slice(0, -1); // Remove the trailing * 42 | } 43 | 44 | workflow += `\n ${repo}`; 45 | }); 46 | } 47 | workflow += "\n"; 48 | 49 | // Write the workflow to the file 50 | await fs.writeFile(filename, workflow); 51 | return true; 52 | } 53 | -------------------------------------------------------------------------------- /addSecurity.test.js: -------------------------------------------------------------------------------- 1 | import { beforeEach, jest } from "@jest/globals"; 2 | import debugLib from "debug"; 3 | const debug = debugLib("pin-github-action-test"); 4 | 5 | jest.unstable_mockModule("fs/promises", () => ({ 6 | stat: jest.fn(), 7 | writeFile: jest.fn(), 8 | })); 9 | 10 | beforeEach(() => { 11 | jest.clearAllMocks(); 12 | }); 13 | 14 | const fs = await import("fs/promises"); 15 | const { default: run } = await import("./addSecurity"); 16 | 17 | const addSecurity = async (filename, allowed) => { 18 | return run.apply(null, [filename, allowed, debug]); 19 | }; 20 | 21 | test("writes config if file does not exist", async () => { 22 | fs.stat.mockImplementation(() => { 23 | return Promise.reject({ code: "ENOENT" }); 24 | }); 25 | fs.writeFile.mockImplementation(() => { 26 | return Promise.resolve(); 27 | }); 28 | const actual = await addSecurity(".github/workflows/security.yaml", []); 29 | expect(actual).toBe(true); 30 | }); 31 | 32 | test("writes allowlisted config correctly", async () => { 33 | fs.stat.mockImplementation(() => { 34 | return Promise.reject({ code: "ENOENT" }); 35 | }); 36 | fs.writeFile.mockImplementation(() => { 37 | return Promise.resolve(); 38 | }); 39 | await addSecurity(".github/workflows/security.yaml", [ 40 | "Demo1/*", 41 | "Demo2/*", 42 | "Demo3/*", 43 | ]); 44 | expect(fs.writeFile).toHaveBeenCalledWith( 45 | ".github/workflows/security.yaml", 46 | expect.stringContaining(` 47 | with: 48 | allowlist: | 49 | Demo1/ 50 | Demo2/ 51 | Demo3/`), 52 | ); 53 | }); 54 | -------------------------------------------------------------------------------- /bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from "fs"; 4 | import { program } from "commander"; 5 | import debugLib from "debug"; 6 | const debug = debugLib("pin-github-action"); 7 | 8 | import run from "./index.js"; 9 | import collectWorkflowFiles from "./collectWorkflowFiles.js"; 10 | import addSecurity from "./addSecurity.js"; 11 | 12 | const mainDebug = debug.extend("main-program"); 13 | const packageDetails = JSON.parse( 14 | fs.readFileSync(new URL("./package.json", import.meta.url)), 15 | ); 16 | 17 | (async () => { 18 | try { 19 | // Allow for command line arguments 20 | program 21 | .name("pin-github-action") 22 | .version(packageDetails.version) 23 | .usage("[options] ") 24 | .argument("", "YAML file or directory to process") 25 | .option( 26 | "-a, --allow ", 27 | "comma separated list of actions to allow e.g. mheap/debug-action. May be a glob e.g. mheap/*", 28 | ) 29 | .option( 30 | "-i, --ignore-shas", 31 | "do not update any commits that are pinned at a sha", 32 | ) 33 | .option( 34 | "-e, --allow-empty", 35 | "allow workflows that do not contain any actions", 36 | ) 37 | .option( 38 | "-c, --comment ", 39 | "comment to add inline when pinning an action", 40 | " {ref}", 41 | ) 42 | .option( 43 | "--continue-on-error", 44 | "continue processing files even if an error occurs", 45 | ) 46 | .option( 47 | "--recursive", 48 | "when processing directories, search recursively for YAML files", 49 | ) 50 | .option( 51 | "--enforce ", 52 | "create a workflow at that ensures all actions are pinned", 53 | ) 54 | .parse(process.argv); 55 | 56 | if (program.args.length === 0) { 57 | program.help(); 58 | } 59 | 60 | let allowed = program.opts().allow; 61 | allowed = (allowed || "").split(",").filter((r) => r); 62 | let ignoreShas = program.opts().ignoreShas; 63 | let allowEmpty = program.opts().allowEmpty; 64 | let comment = program.opts().comment; 65 | const continueOnError = program.opts().continueOnError; 66 | 67 | // Check if we're adding the security file 68 | if (program.opts().enforce) { 69 | const enforceFile = program.opts().enforce; 70 | mainDebug("Adding security workflow to " + enforceFile); 71 | await addSecurity(enforceFile, allowed, mainDebug); 72 | } 73 | 74 | for (const pathname of program.args) { 75 | if (!fs.existsSync(pathname)) { 76 | throw "No such file or directory: " + pathname; 77 | } 78 | } 79 | 80 | const filesToProcess = collectWorkflowFiles( 81 | program.args, 82 | program.opts().recursive, 83 | ); 84 | 85 | if (filesToProcess.length === 0) { 86 | throw "Didn't find Y(A)ML files in provided paths: " + program.args; 87 | } 88 | 89 | for (const filename of filesToProcess) { 90 | mainDebug("Processing " + filename); 91 | const input = fs.readFileSync(filename).toString(); 92 | let output; 93 | try { 94 | output = await run( 95 | input, 96 | allowed, 97 | ignoreShas, 98 | allowEmpty, 99 | debug, 100 | comment, 101 | ); 102 | } catch (e) { 103 | if (!continueOnError) { 104 | throw e; 105 | } else { 106 | console.error( 107 | `Failed to process ${filename} due to: ${e.message || e}`, 108 | ); 109 | console.error( 110 | `Continuing with next file because multiple files are being processed...`, 111 | ); 112 | continue; 113 | } 114 | } 115 | fs.writeFileSync(filename, output.input); 116 | } 117 | 118 | // Once run on a schedule, have it return a list of changes, along with SHA links 119 | // and generate a PR to update the actions to the latest version. This allows them a 120 | // single click to review the current state of the action. Also provide a compare link 121 | // between the new and the old versions of the action. 122 | // 123 | // Should we support auto-assigning the PR using INPUT_ASSIGNEE? I think so, but make 124 | // it optional 125 | } catch (e) { 126 | console.log(e.message || e); 127 | process.exit(1); 128 | } 129 | })(); 130 | -------------------------------------------------------------------------------- /checkAllowedRepos.js: -------------------------------------------------------------------------------- 1 | import { matcher } from "matcher"; 2 | 3 | let debug = () => {}; 4 | export default function (input, ignored, log) { 5 | debug = log.extend("check-allowed-repos"); 6 | // Nothing ignored, so it can't match 7 | if (ignored.length === 0) { 8 | return false; 9 | } 10 | 11 | // Exact match 12 | if (ignored.includes(input)) { 13 | debug(`Skipping ${input} due to exact match in ${ignored}`); 14 | return true; 15 | } 16 | 17 | // Glob match 18 | const isMatch = matcher([input], ignored).length > 0; 19 | 20 | if (isMatch) { 21 | debug(`Skipping ${input} due to pattern match in ${ignored}`); 22 | } 23 | return isMatch; 24 | } 25 | -------------------------------------------------------------------------------- /checkIgnoredRepos.test.js: -------------------------------------------------------------------------------- 1 | import debugLib from "debug"; 2 | const debug = debugLib("pin-github-action-test"); 3 | import run from "./checkAllowedRepos"; 4 | const checkAllowedRepos = (input, ignore) => { 5 | return run.apply(null, [input, ignore, debug]); 6 | }; 7 | 8 | test("empty allow list", () => { 9 | const actual = checkAllowedRepos("mheap/demo", []); 10 | expect(actual).toBe(false); 11 | }); 12 | 13 | test("no match", () => { 14 | expect(checkAllowedRepos("mheap/demo", ["other/repo"])).toBe(false); 15 | }); 16 | 17 | test("exact match", () => { 18 | expect(checkAllowedRepos("mheap/demo", ["mheap/demo"])).toBe(true); 19 | }); 20 | 21 | test("partial match", () => { 22 | expect(checkAllowedRepos("mheap/demo", ["mheap/*"])).toBe(true); 23 | }); 24 | 25 | test("no partial match", () => { 26 | expect(checkAllowedRepos("other/demo", ["mheap/*"])).toBe(false); 27 | }); 28 | 29 | test("multiple ignores", () => { 30 | expect(checkAllowedRepos("mheap/demo", ["other", "mheap/*"])).toBe(true); 31 | }); 32 | 33 | test("negative ignores", () => { 34 | expect(checkAllowedRepos("other/demo", ["!mheap/*"])).toBe(true); 35 | }); 36 | -------------------------------------------------------------------------------- /collectWorkflowFiles.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import glob from "fast-glob"; 4 | 5 | export default function (programArgs, recursive) { 6 | let filesToProcess = []; 7 | 8 | for (let pathname of programArgs) { 9 | if (process.env.WORKFLOWS_DIR) { 10 | // If WORKFLOWS_DIR is set, prepend it to the pathname 11 | pathname = path.join(process.env.WORKFLOWS_DIR, pathname); 12 | } 13 | 14 | if (fs.lstatSync(pathname).isFile()) { 15 | filesToProcess.push(pathname); 16 | } else { 17 | let globPattern = "*.{yaml,yml}"; 18 | if (recursive) { 19 | globPattern = "**/*.{yaml,yml}"; 20 | } 21 | 22 | const files = glob.sync(path.join(`${pathname}`, globPattern)); 23 | filesToProcess = filesToProcess.concat(files); 24 | } 25 | } 26 | 27 | // If user will pass both file and directory, make sure to clear duplicates 28 | return [...new Set(filesToProcess)]; 29 | } 30 | -------------------------------------------------------------------------------- /collectWorkflowFiles.test.js: -------------------------------------------------------------------------------- 1 | import collectWorkflowFiles from "./collectWorkflowFiles.js"; 2 | 3 | test("collects all files from given path without duplicates", () => { 4 | const programArgs = ["__fixtures__/composite.yml", "__fixtures__/"]; 5 | 6 | expect(collectWorkflowFiles(programArgs)).toEqual([ 7 | "__fixtures__/composite.yml", 8 | "__fixtures__/reusable.yml", 9 | "__fixtures__/workflow.yml", 10 | ]); 11 | }); 12 | 13 | test("collects all files from given path", () => { 14 | const programArgs = ["__fixtures__/"]; 15 | 16 | expect(collectWorkflowFiles(programArgs)).toEqual([ 17 | "__fixtures__/composite.yml", 18 | "__fixtures__/reusable.yml", 19 | "__fixtures__/workflow.yml", 20 | ]); 21 | }); 22 | 23 | test("collects files recursively if needed", () => { 24 | const programArgs = ["__fixtures__"]; 25 | 26 | expect(collectWorkflowFiles(programArgs, true)).toEqual([ 27 | "__fixtures__/composite.yml", 28 | "__fixtures__/reusable.yml", 29 | "__fixtures__/workflow.yml", 30 | "__fixtures__/recursive/works.yml", 31 | ]); 32 | }); 33 | 34 | test("collects single file from given path", () => { 35 | const programArgs = ["__fixtures__/composite.yml"]; 36 | 37 | expect(collectWorkflowFiles(programArgs)).toEqual([ 38 | "__fixtures__/composite.yml", 39 | ]); 40 | }); 41 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "eslint/config"; 2 | import prettier from "eslint-plugin-prettier"; 3 | 4 | export default defineConfig([{ 5 | plugins: { 6 | prettier, 7 | }, 8 | 9 | languageOptions: { 10 | ecmaVersion: 2023, 11 | sourceType: "module", 12 | 13 | parserOptions: { 14 | impliedStrict: true, 15 | }, 16 | }, 17 | 18 | rules: { 19 | "prettier/prettier": "error", 20 | curly: ["error", "all"], 21 | }, 22 | }]); -------------------------------------------------------------------------------- /extractActions.js: -------------------------------------------------------------------------------- 1 | let debug = () => {}; 2 | export default function (input, allowEmpty, comment, log) { 3 | debug = log.extend("extract-actions"); 4 | // Check if it's a composite action 5 | let runs = input.contents.items.filter((n) => n.key == "runs"); 6 | if (runs.length) { 7 | debug("Processing composite action"); 8 | return extractFromComposite(input, allowEmpty, comment); 9 | } 10 | 11 | debug("Processing workflow"); 12 | return extractFromWorkflow(input, allowEmpty, comment); 13 | } 14 | 15 | function extractFromComposite(input, allowEmpty, comment) { 16 | let runs = input.contents.items.filter((n) => n.key == "runs"); 17 | let steps = runs[0].value.items.filter((n) => n.key == "steps"); 18 | 19 | if (!steps.length) { 20 | throw new Error("No runs.steps found"); 21 | } 22 | 23 | const actions = new Set(); 24 | steps = steps[0].value.items; 25 | for (let step of steps) { 26 | handleStep(actions, step.items, comment); 27 | } 28 | return Array.from(actions); 29 | } 30 | 31 | function extractFromWorkflow(input, allowEmpty, comment) { 32 | let actions = new Set(); 33 | 34 | let jobs = input.contents.items.filter((n) => n.key == "jobs"); 35 | if (!jobs.length) { 36 | throw new Error("No jobs found"); 37 | } 38 | jobs = jobs[0].value.items; 39 | if (!jobs.length) { 40 | throw new Error("No jobs found"); 41 | } 42 | 43 | for (let job of jobs) { 44 | if (!job.value) { 45 | throw new Error( 46 | "Error parsing workflow. Is this a valid GitHub Actions workflow?\n\n" + 47 | input.toString(), 48 | ); 49 | } 50 | 51 | let steps = job.value.items.filter( 52 | (n) => n.key == "steps" || n.key == "uses", 53 | ); 54 | if (!steps.length) { 55 | throw new Error("No job.steps or job.uses found"); 56 | } 57 | 58 | // It's a job with steps 59 | if (steps[0].value.items) { 60 | if (!steps[0].value.items.length) { 61 | throw new Error("No job.steps found"); 62 | } 63 | 64 | for (let step of steps[0].value.items) { 65 | handleStep(actions, step.items, comment); 66 | } 67 | } else { 68 | // It's a job that calls a reusable workflow 69 | handleStep(actions, steps, comment); 70 | } 71 | } 72 | 73 | if (actions.size === 0 && !allowEmpty) { 74 | throw new Error("No Actions detected"); 75 | } 76 | 77 | return Array.from(actions); 78 | } 79 | 80 | function handleStep(actions, items, comment) { 81 | const uses = items.filter((n) => n.key == "uses"); 82 | 83 | for (let use of uses) { 84 | const line = use.value.value.toString(); 85 | 86 | if (line.substr(0, 9) == "docker://") { 87 | debug(`Skipping docker:// action: '${line}'`); 88 | continue; 89 | } 90 | if (line.substr(0, 2) == "./") { 91 | debug(`Skipping local action: '${line}'`); 92 | continue; 93 | } 94 | 95 | const details = parseAction(line); 96 | 97 | comment = comment.replace("{ref}", ""); 98 | 99 | let original = (use.value.comment || "").replace(comment, ""); 100 | if (!original) { 101 | original = details.currentVersion; 102 | } 103 | 104 | // Legacy format, strip pin@ off 105 | if (original.includes("pin@")) { 106 | original = original.replace("pin@", ""); 107 | } 108 | 109 | actions.add({ ...details, pinnedVersion: original }); 110 | } 111 | 112 | return actions; 113 | } 114 | 115 | function parseAction(str) { 116 | const [name, version] = str.split("@"); 117 | 118 | // The action could be in a subdirectory 119 | let [owner, repo, ...path] = name.split("/"); 120 | path = path.join("/"); 121 | return { owner, repo, currentVersion: version, path }; 122 | } 123 | -------------------------------------------------------------------------------- /extractActions.test.js: -------------------------------------------------------------------------------- 1 | import YAML from "yaml"; 2 | import debugLib from "debug"; 3 | const debug = debugLib("pin-github-action-test"); 4 | 5 | import run from "./extractActions"; 6 | const extractActions = (input, allowEmpty, comment) => { 7 | if (!comment) { 8 | comment = " {ref}"; 9 | } 10 | return run.apply(null, [input, allowEmpty, comment, debug]); 11 | }; 12 | 13 | test("extracts a single version", () => { 14 | const input = convertToAst({ 15 | name: "PR", 16 | on: ["pull_request"], 17 | jobs: { 18 | "test-job": { 19 | "runs-on": "ubuntu-latest", 20 | steps: [ 21 | { 22 | name: "Test Action Step", 23 | uses: "mheap/test-action@master", 24 | }, 25 | ], 26 | }, 27 | }, 28 | }); 29 | 30 | const actual = extractActions(input); 31 | 32 | expect(actual).toEqual([ 33 | { 34 | owner: "mheap", 35 | repo: "test-action", 36 | path: "", 37 | currentVersion: "master", 38 | pinnedVersion: "master", 39 | }, 40 | ]); 41 | }); 42 | 43 | test("extracts a pinned version", () => { 44 | const input = YAML.parseDocument(` 45 | name: PR 46 | on: 47 | - pull_request 48 | jobs: 49 | test-job: 50 | runs-on: ubuntu-latest 51 | steps: 52 | - name: Test Action Step 53 | uses: "mheap/test-action@abc123" # master`); 54 | 55 | const actual = extractActions(input); 56 | 57 | expect(actual).toEqual([ 58 | { 59 | owner: "mheap", 60 | repo: "test-action", 61 | path: "", 62 | currentVersion: "abc123", 63 | pinnedVersion: "master", 64 | }, 65 | ]); 66 | }); 67 | 68 | test("extracts a legacy pinned version", () => { 69 | const input = YAML.parseDocument(` 70 | name: PR 71 | on: 72 | - pull_request 73 | jobs: 74 | test-job: 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: Test Action Step 78 | uses: "mheap/test-action@abc123" # pin@master`); 79 | 80 | const actual = extractActions(input); 81 | 82 | expect(actual).toEqual([ 83 | { 84 | owner: "mheap", 85 | repo: "test-action", 86 | path: "", 87 | currentVersion: "abc123", 88 | pinnedVersion: "master", 89 | }, 90 | ]); 91 | }); 92 | 93 | test("extracts a pinned version with a custom comment", () => { 94 | const input = YAML.parseDocument(` 95 | name: PR 96 | on: 97 | - pull_request 98 | jobs: 99 | test-job: 100 | runs-on: ubuntu-latest 101 | steps: 102 | - name: Test Action Step 103 | uses: "mheap/test-action@abc123" # 1.4.0`); 104 | 105 | const actual = extractActions(input, false, " {ref}"); 106 | 107 | expect(actual).toEqual([ 108 | { 109 | owner: "mheap", 110 | repo: "test-action", 111 | path: "", 112 | currentVersion: "abc123", 113 | pinnedVersion: "1.4.0", 114 | }, 115 | ]); 116 | }); 117 | 118 | test("extracts a single version in a subfolder", () => { 119 | const input = convertToAst({ 120 | name: "PR", 121 | on: ["pull_request"], 122 | jobs: { 123 | "test-job": { 124 | "runs-on": "ubuntu-latest", 125 | steps: [ 126 | { 127 | name: "Test Action Step", 128 | uses: "mheap/test-action/action-one/src@master", 129 | }, 130 | ], 131 | }, 132 | }, 133 | }); 134 | 135 | const actual = extractActions(input); 136 | expect(actual).toEqual([ 137 | { 138 | owner: "mheap", 139 | repo: "test-action", 140 | path: "action-one/src", 141 | currentVersion: "master", 142 | pinnedVersion: "master", 143 | }, 144 | ]); 145 | }); 146 | 147 | test("extracts a complex version", () => { 148 | const input = convertToAst({ 149 | name: "PR", 150 | on: ["pull_request"], 151 | jobs: { 152 | "test-job": { 153 | "runs-on": "ubuntu-latest", 154 | steps: [ 155 | { 156 | name: "Test Action Step", 157 | uses: "mheap/test-action@master", 158 | }, 159 | { 160 | name: "Another Step", 161 | uses: "mheap/second-action@v1", 162 | }, 163 | ], 164 | }, 165 | "separate-job": { 166 | "runs-on": "ubuntu-latest", 167 | steps: [ 168 | { 169 | name: "Parallel Job Step", 170 | uses: "mheap/separate-action@v2.1.5", 171 | }, 172 | ], 173 | }, 174 | }, 175 | }); 176 | 177 | const actual = extractActions(input); 178 | expect(actual).toEqual([ 179 | { 180 | owner: "mheap", 181 | repo: "test-action", 182 | path: "", 183 | currentVersion: "master", 184 | pinnedVersion: "master", 185 | }, 186 | { 187 | owner: "mheap", 188 | repo: "second-action", 189 | path: "", 190 | currentVersion: "v1", 191 | pinnedVersion: "v1", 192 | }, 193 | { 194 | owner: "mheap", 195 | repo: "separate-action", 196 | path: "", 197 | currentVersion: "v2.1.5", 198 | pinnedVersion: "v2.1.5", 199 | }, 200 | ]); 201 | }); 202 | 203 | test("skips docker actions", () => { 204 | const input = convertToAst({ 205 | name: "PR", 206 | on: ["pull_request"], 207 | jobs: { 208 | "test-job": { 209 | "runs-on": "ubuntu-latest", 210 | steps: [ 211 | { 212 | name: "Test Action Step", 213 | uses: "mheap/test-action@master", 214 | }, 215 | { 216 | name: "Docker Step", 217 | uses: "docker://alpine:3.8", 218 | }, 219 | ], 220 | }, 221 | }, 222 | }); 223 | 224 | const actual = extractActions(input); 225 | expect(actual).toEqual([ 226 | { 227 | owner: "mheap", 228 | repo: "test-action", 229 | path: "", 230 | currentVersion: "master", 231 | pinnedVersion: "master", 232 | }, 233 | ]); 234 | }); 235 | 236 | test("skips local actions", () => { 237 | const input = convertToAst({ 238 | name: "PR", 239 | on: ["pull_request"], 240 | jobs: { 241 | "test-job": { 242 | "runs-on": "ubuntu-latest", 243 | steps: [ 244 | { 245 | name: "Test Action Step", 246 | uses: "mheap/test-action@master", 247 | }, 248 | { 249 | name: "Local Step", 250 | uses: "./local-action", 251 | }, 252 | ], 253 | }, 254 | }, 255 | }); 256 | 257 | const actual = extractActions(input); 258 | expect(actual).toEqual([ 259 | { 260 | owner: "mheap", 261 | repo: "test-action", 262 | path: "", 263 | currentVersion: "master", 264 | pinnedVersion: "master", 265 | }, 266 | ]); 267 | }); 268 | 269 | test("throws with missing jobs", () => { 270 | const input = convertToAst({ 271 | name: "PR", 272 | on: ["pull_request"], 273 | }); 274 | 275 | const actual = () => extractActions(input); 276 | expect(actual).toThrow("No jobs found"); 277 | }); 278 | 279 | test("throws with empty jobs", () => { 280 | const input = convertToAst({ 281 | name: "PR", 282 | on: ["pull_request"], 283 | jobs: {}, 284 | }); 285 | 286 | const actual = () => extractActions(input); 287 | expect(actual).toThrow("No jobs found"); 288 | }); 289 | 290 | test("throws with missing steps", () => { 291 | const input = convertToAst({ 292 | name: "PR", 293 | on: ["pull_request"], 294 | jobs: { 295 | "test-job": { 296 | "runs-on": "ubuntu-latest", 297 | }, 298 | }, 299 | }); 300 | 301 | const actual = () => extractActions(input); 302 | expect(actual).toThrow("No job.steps or job.uses found"); 303 | }); 304 | 305 | test("throws with empty steps", () => { 306 | const input = convertToAst({ 307 | name: "PR", 308 | on: ["pull_request"], 309 | jobs: { 310 | "test-job": { 311 | "runs-on": "ubuntu-latest", 312 | steps: [], 313 | }, 314 | }, 315 | }); 316 | 317 | const actual = () => extractActions(input); 318 | expect(actual).toThrow("No job.steps found"); 319 | }); 320 | 321 | test("throws with missing uses", () => { 322 | const input = convertToAst({ 323 | name: "PR", 324 | on: ["pull_request"], 325 | jobs: { 326 | "test-job": { 327 | "runs-on": "ubuntu-latest", 328 | steps: [ 329 | { 330 | name: "Test Action Step", 331 | run: "echo 'Hello World'", 332 | }, 333 | ], 334 | }, 335 | }, 336 | }); 337 | 338 | const actual = () => extractActions(input); 339 | expect(actual).toThrow("No Actions detected"); 340 | }); 341 | 342 | test("does not throw with missing uses when allowEmpty is enabled", () => { 343 | const input = convertToAst({ 344 | name: "PR", 345 | on: ["pull_request"], 346 | jobs: { 347 | "test-job": { 348 | "runs-on": "ubuntu-latest", 349 | steps: [ 350 | { 351 | name: "Test Action Step", 352 | run: "echo 'Hello World'", 353 | }, 354 | ], 355 | }, 356 | }, 357 | }); 358 | 359 | const actual = extractActions(input, true); 360 | expect(actual).toEqual([]); 361 | }); 362 | 363 | test("does not throw when mixing run and uses", () => { 364 | const input = convertToAst({ 365 | name: "PR", 366 | on: ["pull_request"], 367 | jobs: { 368 | "test-job": { 369 | "runs-on": "ubuntu-latest", 370 | steps: [ 371 | { 372 | name: "Test Action Step", 373 | run: "echo 'Hello World'", 374 | }, 375 | { 376 | name: "With An Action", 377 | uses: "mheap/test-action@master", 378 | }, 379 | ], 380 | }, 381 | }, 382 | }); 383 | 384 | const actual = extractActions(input); 385 | 386 | expect(actual).toEqual([ 387 | { 388 | owner: "mheap", 389 | repo: "test-action", 390 | path: "", 391 | currentVersion: "master", 392 | pinnedVersion: "master", 393 | }, 394 | ]); 395 | }); 396 | 397 | test("extracts from composite actions", () => { 398 | const input = convertToAst({ 399 | name: "Sample Composite", 400 | runs: { 401 | using: "composite", 402 | steps: [ 403 | { 404 | name: "With An Action", 405 | uses: "mheap/test-action@master", 406 | }, 407 | ], 408 | }, 409 | }); 410 | 411 | const actual = extractActions(input); 412 | 413 | expect(actual).toEqual([ 414 | { 415 | owner: "mheap", 416 | repo: "test-action", 417 | path: "", 418 | currentVersion: "master", 419 | pinnedVersion: "master", 420 | }, 421 | ]); 422 | }); 423 | 424 | test("extracts from reusable workflows", () => { 425 | const input = convertToAst({ 426 | name: "Sample Reusable", 427 | jobs: { 428 | test: { 429 | uses: "mheap/test-action@master", 430 | }, 431 | }, 432 | }); 433 | 434 | const actual = extractActions(input); 435 | 436 | expect(actual).toEqual([ 437 | { 438 | owner: "mheap", 439 | repo: "test-action", 440 | path: "", 441 | currentVersion: "master", 442 | pinnedVersion: "master", 443 | }, 444 | ]); 445 | }); 446 | 447 | function convertToAst(input) { 448 | return YAML.parseDocument(YAML.stringify(input)); 449 | } 450 | -------------------------------------------------------------------------------- /findRefOnGithub.js: -------------------------------------------------------------------------------- 1 | import { Octokit } from "@octokit/rest"; 2 | let auth = ""; 3 | 4 | // Legacy format 5 | if (process.env.GH_ADMIN_TOKEN) { 6 | auth = process.env.GH_ADMIN_TOKEN; 7 | } 8 | 9 | // New format 10 | if (process.env.GITHUB_TOKEN) { 11 | auth = process.env.GITHUB_TOKEN; 12 | } 13 | 14 | let debug = () => {}; 15 | export default function (action, log) { 16 | debug = log.extend("find-ref-on-github"); 17 | 18 | const github = new Octokit({ 19 | auth, 20 | log: { 21 | warn: debug, 22 | error: debug, 23 | }, 24 | }); 25 | 26 | return new Promise(async function (resolve, reject) { 27 | const owner = action.owner; 28 | const repo = action.repo; 29 | const pinned = action.pinnedVersion; 30 | const name = `${owner}/${repo}`; 31 | 32 | let error; 33 | 34 | // In order: Tag, Branch 35 | const possibleRefs = [`tags/${pinned}`, `heads/${pinned}`]; 36 | for (let ref of possibleRefs) { 37 | try { 38 | debug(`Fetching ref ${ref}`); 39 | const object = ( 40 | await github.git.getRef({ 41 | owner, 42 | repo, 43 | ref, 44 | }) 45 | ).data.object; 46 | 47 | // If it's a tag, fetch the commit hash instead 48 | if (object.type === "tag") { 49 | debug(`[${name}] Ref is a tag. Fetch commit hash instead`); 50 | // Fetch the commit hash instead 51 | const tag = await github.git.getTag({ 52 | owner, 53 | repo, 54 | tag_sha: object.sha, 55 | }); 56 | debug(`[${name}] Fetched commit. Found SHA.`); 57 | return resolve(tag.data.object.sha); 58 | } 59 | 60 | // If it's already a commit, return that 61 | if (object.type === "commit") { 62 | debug(`[${name}] Ref is a commit. Found SHA.`); 63 | return resolve(object.sha); 64 | } 65 | } catch (e) { 66 | // We can ignore failures as we validate later 67 | debug(`[${name}] Error fetching ref: ${e.message}`); 68 | error = handleCommonErrors(e, name); 69 | } 70 | } 71 | 72 | // If we get this far, have we been provided with a specific commit SHA? 73 | try { 74 | debug( 75 | `[${name}] Provided version is not a ref. Checking if it's a commit SHA`, 76 | ); 77 | const commit = await github.repos.getCommit({ 78 | owner, 79 | repo, 80 | ref: pinned, 81 | }); 82 | return resolve(commit.data.sha); 83 | } catch (e) { 84 | // If it's not a commit, it doesn't matter 85 | debug(`[${name}] Error fetching commit: ${e.message}`); 86 | error = handleCommonErrors(e, name); 87 | } 88 | 89 | return reject( 90 | `Unable to find SHA for ${owner}/${repo}@${pinned}\n${error}`, 91 | ); 92 | }); 93 | } 94 | 95 | function handleCommonErrors(e, name) { 96 | if (e.status == 404) { 97 | debug( 98 | `[${name}] ERROR: Could not find repo. It may be private, or it may not exist`, 99 | ); 100 | return "Private repos require you to set process.env.GITHUB_TOKEN to fetch the latest SHA"; 101 | } 102 | 103 | if (e.message.includes("API rate limit exceeded")) { 104 | const resetTime = e.response?.headers["x-ratelimit-reset"]; 105 | const resetDate = resetTime 106 | ? new Date(resetTime * 1000).toLocaleString() 107 | : "unknown"; 108 | debug( 109 | `[${name}] ERROR: Rate Limiting error. Limit resets at: ${resetDate}`, 110 | ); 111 | return `${e.message} Limit resets at: ${resetDate}`; 112 | } 113 | return ""; 114 | } 115 | -------------------------------------------------------------------------------- /findRefOnGithub.test.js: -------------------------------------------------------------------------------- 1 | import nock from "nock"; 2 | nock.disableNetConnect(); 3 | 4 | import debugLib from "debug"; 5 | const debug = debugLib("pin-github-action-test"); 6 | import run from "./findRefOnGithub"; 7 | const findRef = (action) => { 8 | return run.apply(null, [action, debug]); 9 | }; 10 | 11 | const action = { 12 | owner: "nexmo", 13 | repo: "github-actions", 14 | path: "", 15 | pinnedVersion: "master", 16 | currentVersion: "master", 17 | }; 18 | 19 | afterEach(() => { 20 | if (!nock.isDone()) { 21 | throw new Error( 22 | `Not all nock interceptors were used: ${JSON.stringify( 23 | nock.pendingMocks(), 24 | )}`, 25 | ); 26 | } 27 | nock.cleanAll(); 28 | }); 29 | 30 | test("looks up a specific hash", async () => { 31 | const testAction = { 32 | ...action, 33 | pinnedVersion: "73549280c1c566830040d9a01fe9050dae6a3036", 34 | }; 35 | mockRefLookupFailure( 36 | testAction, 37 | "tags/73549280c1c566830040d9a01fe9050dae6a3036", 38 | ); 39 | mockRefLookupFailure( 40 | testAction, 41 | "heads/73549280c1c566830040d9a01fe9050dae6a3036", 42 | ); 43 | mockCommitLookupSuccess( 44 | testAction, 45 | "73549280c1c566830040d9a01fe9050dae6a3036", 46 | ); 47 | const actual = await findRef(testAction); 48 | expect(actual).toEqual("73549280c1c566830040d9a01fe9050dae6a3036"); 49 | }); 50 | 51 | test("looks up a tag", async () => { 52 | const testAction = { ...action, pinnedVersion: "v1" }; 53 | mockTagRefLookupSuccess( 54 | testAction, 55 | "tags/v1", 56 | "73549280c1c566830040d9a01fe9050dae6a3036", 57 | ); 58 | mockTagLookupSuccess( 59 | testAction, 60 | "73549280c1c566830040d9a01fe9050dae6a3036", 61 | "62ffef0ba7de4e1410b3c503be810ec23842e34a", 62 | ); 63 | const actual = await findRef(testAction); 64 | expect(actual).toEqual("62ffef0ba7de4e1410b3c503be810ec23842e34a"); 65 | }); 66 | 67 | test("looks up a branch", async () => { 68 | mockRefLookupFailure(action, "tags/master"); 69 | mockBranchRefLookupSuccess( 70 | action, 71 | "heads/master", 72 | "73549280c1c566830040d9a01fe9050dae6a3036", 73 | ); 74 | const actual = await findRef(action); 75 | expect(actual).toEqual("73549280c1c566830040d9a01fe9050dae6a3036"); 76 | }); 77 | 78 | test("fails to find ref (404)", () => { 79 | mockRefLookupFailure(action, "tags/master"); 80 | mockRefLookupFailure(action, "heads/master"); 81 | mockCommitLookupFailure(action, "master"); 82 | return expect(findRef(action)).rejects.toEqual( 83 | `Unable to find SHA for nexmo/github-actions@master\nPrivate repos require you to set process.env.GITHUB_TOKEN to fetch the latest SHA`, 84 | ); 85 | }); 86 | 87 | test("fails to find ref (rate limiting)", () => { 88 | mockRefLookupFailure(action, "tags/master"); 89 | mockRefLookupFailure(action, "heads/master"); 90 | mockCommitLookupRateLimit(action, "master"); 91 | const resetDate = new Date(1744211324000).toLocaleString(); 92 | return expect(findRef(action)).rejects.toEqual( 93 | `Unable to find SHA for nexmo/github-actions@master\nAPI rate limit exceeded for 1.2.3.4. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.) Limit resets at: ${resetDate}`, 94 | ); 95 | }); 96 | 97 | function mockRefLookupFailure(action, path) { 98 | path = path.replace("/", "%2F"); 99 | nock("https://api.github.com") 100 | .get(`/repos/${action.owner}/${action.repo}/git/ref/${path}`) 101 | .reply(404); 102 | } 103 | 104 | function mockBranchRefLookupSuccess(action, path, sha) { 105 | path = path.replace("/", "%2F"); 106 | const data = { 107 | ref: `refs/${path}`, 108 | object: { 109 | sha: sha, 110 | type: "commit", 111 | }, 112 | }; 113 | nock("https://api.github.com") 114 | .get(`/repos/${action.owner}/${action.repo}/git/ref/${path}`) 115 | .reply(200, data); 116 | } 117 | 118 | function mockTagRefLookupSuccess(action, path, sha) { 119 | path = path.replace("/", "%2F"); 120 | const data = { 121 | ref: `refs/${path}`, 122 | object: { 123 | sha: sha, 124 | type: "tag", 125 | }, 126 | }; 127 | nock("https://api.github.com") 128 | .get(`/repos/${action.owner}/${action.repo}/git/ref/${path}`) 129 | .reply(200, data); 130 | } 131 | 132 | function mockTagLookupSuccess(action, tagSha, commitSha) { 133 | const data = { 134 | object: { 135 | sha: commitSha, 136 | type: "commit", 137 | }, 138 | }; 139 | 140 | nock("https://api.github.com") 141 | .get(`/repos/${action.owner}/${action.repo}/git/tags/${tagSha}`) 142 | .reply(200, data); 143 | } 144 | 145 | function mockCommitLookupSuccess(action, commitSha) { 146 | nock("https://api.github.com") 147 | .get(`/repos/${action.owner}/${action.repo}/commits/${commitSha}`) 148 | .reply(200, { 149 | sha: commitSha, 150 | }); 151 | } 152 | 153 | function mockCommitLookupFailure(action, commitSha) { 154 | nock("https://api.github.com") 155 | .get(`/repos/${action.owner}/${action.repo}/commits/${commitSha}`) 156 | .reply(404); 157 | } 158 | 159 | function mockCommitLookupRateLimit(action, commitSha) { 160 | nock("https://api.github.com") 161 | .get(`/repos/${action.owner}/${action.repo}/commits/${commitSha}`) 162 | .reply( 163 | 429, 164 | "API rate limit exceeded for 1.2.3.4. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)", 165 | { 166 | "x-ratelimit-reset": "1744211324", 167 | }, 168 | ); 169 | } 170 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import YAML from "yaml"; 2 | import extractActions from "./extractActions.js"; 3 | import replaceActions from "./replaceActions.js"; 4 | import findRefOnGithub from "./findRefOnGithub.js"; 5 | import checkAllowedRepos from "./checkAllowedRepos.js"; 6 | import isSha from "./isSha.js"; 7 | 8 | export default async function ( 9 | input, 10 | allowed, 11 | ignoreShas, 12 | allowEmpty, 13 | debug, 14 | comment, 15 | ) { 16 | allowed = allowed || []; 17 | ignoreShas = ignoreShas || false; 18 | 19 | // Parse the workflow file 20 | let workflow = YAML.parseDocument(input); 21 | 22 | // Extract list of actions 23 | let actions = extractActions(workflow, allowEmpty, comment, debug); 24 | 25 | for (let i in actions) { 26 | // Should this action be updated? 27 | const action = `${actions[i].owner}/${actions[i].repo}`; 28 | if (checkAllowedRepos(action, allowed, debug)) { 29 | continue; 30 | } 31 | 32 | if (ignoreShas && isSha(actions[i])) { 33 | continue; 34 | } 35 | 36 | // Look up those actions on Github 37 | const newVersion = await findRefOnGithub(actions[i], debug); 38 | actions[i].newVersion = newVersion; 39 | 40 | // Rewrite each action, replacing the uses block with a specific SHA 41 | input = replaceActions(input, actions[i], comment); 42 | } 43 | 44 | return { 45 | input, 46 | actions, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /isSha.js: -------------------------------------------------------------------------------- 1 | export default function (action) { 2 | return /\b([a-f0-9]{40})\b/.test(action.currentVersion); 3 | } 4 | -------------------------------------------------------------------------------- /isSha.test.js: -------------------------------------------------------------------------------- 1 | import isSha from "./isSha"; 2 | 3 | test("returns false if a non-SHA is provided", () => { 4 | const actual = isSha({ 5 | currentVersion: "main", 6 | }); 7 | expect(actual).toBe(false); 8 | }); 9 | 10 | test("returns true if a SHA is provided", () => { 11 | const actual = isSha({ 12 | currentVersion: "1cb496d922065fc73c8f2ff3cc33d9b251ef1aa7", 13 | }); 14 | expect(actual).toBe(true); 15 | }); 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pin-github-action", 3 | "version": "3.3.1", 4 | "description": "Pin your GitHub Actions to specific versions automatically!", 5 | "exports": "./index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "cross-env NODE_OPTIONS=--experimental-vm-modules NODE_NO_WARNINGS=1 jest", 9 | "lint": "eslint *.js", 10 | "lint-fix": "eslint --fix *.js" 11 | }, 12 | "keywords": [ 13 | "github actions", 14 | "github", 15 | "security" 16 | ], 17 | "author": "Michael Heap ", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "cross-env": "^7.0.3", 21 | "eslint": "^9", 22 | "eslint-plugin-prettier": "^5", 23 | "jest": "^29", 24 | "nock": "^14", 25 | "prettier": "^3" 26 | }, 27 | "dependencies": { 28 | "@octokit/rest": "^21", 29 | "commander": "^13", 30 | "debug": "^4.4.0", 31 | "escape-string-regexp": "^5.0.0", 32 | "fast-glob": "^3.3.3", 33 | "matcher": "^5.0.0", 34 | "yaml": "^2.7.0" 35 | }, 36 | "bin": { 37 | "pin-github-action": "bin.js" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /replaceActions.js: -------------------------------------------------------------------------------- 1 | import escapeStringRegexp from "escape-string-regexp"; 2 | 3 | export default function (input, action, comment) { 4 | const { currentVersion, owner, newVersion, path, repo } = action; 5 | 6 | const actionId = `${owner}/${repo}${path ? `/${path}` : ""}`; 7 | const newComment = generateComment(action, comment); 8 | 9 | // Capture an optional quote (either `"` or `'`) 10 | const quotePattern = "([\"'])?"; 11 | 12 | const regexpUpdate = new RegExp( 13 | `uses(\\s*):(\\s+)${quotePattern}${escapeStringRegexp( 14 | actionId, 15 | )}@${escapeStringRegexp(currentVersion)}\\3([\\t ]*)#[^\\n]*`, 16 | "g", 17 | ); 18 | 19 | if (regexpUpdate.test(input)) { 20 | return input.replace( 21 | regexpUpdate, 22 | `uses$1:$2$3${actionId}@${newVersion}$3$4#${newComment}`, 23 | ); 24 | } 25 | 26 | const regexpPin = new RegExp( 27 | `uses(\\s*):(\\s+)${quotePattern}${escapeStringRegexp( 28 | actionId, 29 | )}@${escapeStringRegexp(currentVersion)}\\3`, 30 | "g", 31 | ); 32 | 33 | return input.replace( 34 | regexpPin, 35 | `uses$1:$2$3${actionId}@${newVersion}$3 #${newComment}`, 36 | ); 37 | } 38 | 39 | function generateComment(action, comment) { 40 | if (!comment) { 41 | comment = ` {ref}`; 42 | } 43 | return `${comment.replace("{ref}", action.pinnedVersion)}`; 44 | } 45 | -------------------------------------------------------------------------------- /replaceActions.test.js: -------------------------------------------------------------------------------- 1 | import replaceActions from "./replaceActions"; 2 | import YAML from "yaml"; 3 | 4 | const action = { 5 | owner: "mheap", 6 | repo: "test-action", 7 | path: "", 8 | pinnedVersion: "master", 9 | currentVersion: "master", 10 | newVersion: "sha-here", 11 | }; 12 | 13 | test("replaces a single action with a SHA (workflow)", () => { 14 | const input = convertToYaml({ 15 | name: "PR", 16 | on: ["pull_request"], 17 | jobs: { 18 | "test-job": { 19 | "runs-on": "ubuntu-latest", 20 | steps: [ 21 | { 22 | name: "test action step", 23 | uses: "mheap/test-action@master", 24 | }, 25 | ], 26 | }, 27 | }, 28 | }); 29 | 30 | const actual = replaceActions(input, { ...action }); 31 | 32 | expect(actual).toContain("uses: mheap/test-action@sha-here # master"); 33 | }); 34 | 35 | test("supports a custom comment format (workflow)", () => { 36 | const input = convertToYaml({ 37 | name: "PR", 38 | on: ["pull_request"], 39 | jobs: { 40 | "test-job": { 41 | "runs-on": "ubuntu-latest", 42 | steps: [ 43 | { 44 | name: "test action step", 45 | uses: "mheap/test-action@master", 46 | }, 47 | ], 48 | }, 49 | }, 50 | }); 51 | 52 | const actual = replaceActions(input, { ...action }, " {ref}"); 53 | 54 | expect(actual).toContain("uses: mheap/test-action@sha-here # master"); 55 | }); 56 | 57 | test("replaces a single action with a SHA (composite)", () => { 58 | const input = convertToYaml({ 59 | name: "Sample Composite", 60 | runs: { 61 | using: "composite", 62 | steps: [ 63 | { 64 | name: "With An Action", 65 | uses: "mheap/test-action@master", 66 | }, 67 | ], 68 | }, 69 | }); 70 | 71 | const actual = replaceActions(input, { ...action }); 72 | 73 | expect(actual).toContain("uses: mheap/test-action@sha-here # master"); 74 | }); 75 | 76 | test("supports a custom comment format (composite)", () => { 77 | const input = convertToYaml({ 78 | name: "Sample Composite", 79 | runs: { 80 | using: "composite", 81 | steps: [ 82 | { 83 | name: "With An Action", 84 | uses: "mheap/test-action@master", 85 | }, 86 | ], 87 | }, 88 | }); 89 | 90 | const actual = replaceActions(input, { ...action }, " {ref}"); 91 | 92 | expect(actual).toContain("uses: mheap/test-action@sha-here # master"); 93 | }); 94 | 95 | test("replaces a single action with a SHA (reusable)", () => { 96 | const input = convertToYaml({ 97 | name: "Sample Reusable", 98 | jobs: { 99 | test: { 100 | uses: "mheap/test-action@master", 101 | }, 102 | }, 103 | }); 104 | 105 | const actual = replaceActions(input, { ...action }).toString(); 106 | 107 | expect(actual).toContain("uses: mheap/test-action@sha-here # master"); 108 | }); 109 | 110 | test("supports a custom comment format (reusable)", () => { 111 | const input = convertToYaml({ 112 | name: "Sample Reusable", 113 | jobs: { 114 | test: { 115 | uses: "mheap/test-action@master", 116 | }, 117 | }, 118 | }); 119 | 120 | const actual = replaceActions(input, { ...action }, " {ref}"); 121 | 122 | expect(actual).toContain("uses: mheap/test-action@sha-here # master"); 123 | }); 124 | 125 | test("replaces an existing SHA with a different SHA, not changing the pinned branch", () => { 126 | const input = convertToYaml({ 127 | name: "PR", 128 | on: ["pull_request"], 129 | jobs: { 130 | "test-job": { 131 | "runs-on": "ubuntu-latest", 132 | steps: [ 133 | { 134 | name: "test action step", 135 | uses: "mheap/test-action@sha-one", 136 | }, 137 | ], 138 | }, 139 | }, 140 | }); 141 | 142 | const actual = replaceActions(input, { 143 | ...action, 144 | pinnedVersion: "v1", 145 | currentVersion: "sha-one", 146 | newVersion: "sha-two", 147 | }); 148 | 149 | expect(actual).toContain("uses: mheap/test-action@sha-two # v1"); 150 | }); 151 | 152 | test("maintains formatting when adding a pin", () => { 153 | const input = `name: PR 154 | on: 155 | - pull_request 156 | jobs: 157 | test-job: 158 | runs-on: ubuntu-latest 159 | steps: 160 | - uses: mheap/test-action@master 161 | name: test where uses comes after the dash 162 | 163 | - name: test with extra leading spaces 164 | uses: mheap/test-action@master 165 | 166 | - name: test with space after 'uses' 167 | uses : mheap/test-action@master 168 | 169 | - name: no final newline 170 | uses: mheap/test-action@master`; 171 | 172 | const actual = replaceActions(input, { ...action }); 173 | 174 | expect(actual).toContain("- uses: mheap/test-action@sha-here # master"); 175 | expect(actual).toContain("uses: mheap/test-action@sha-here # master"); 176 | expect(actual).toContain("uses : mheap/test-action@sha-here # master"); 177 | expect(actual).toMatch(/uses: mheap\/test-action@sha-here # master$/); 178 | }); 179 | 180 | test("maintains formatting when updating a pin", () => { 181 | const input = `name: PR 182 | on: 183 | - pull_request 184 | jobs: 185 | test-job: 186 | runs-on: ubuntu-latest 187 | steps: 188 | - uses: mheap/test-action@sha-one # v1 189 | name: test where uses comes after the dash 190 | 191 | - name: test with extra leading spaces 192 | uses: mheap/test-action@sha-one # v1 193 | 194 | - name: test with space after 'uses' 195 | uses : mheap/test-action@sha-one # v1 196 | 197 | - name: test with multiple spaces before the comment 198 | uses: mheap/test-action@sha-one # v1 199 | 200 | - name: test without space after the comment starts 201 | uses: mheap/test-action@sha-one #v1 202 | 203 | - name: test without space before the comment starts 204 | uses: mheap/test-action@sha-one# v1 205 | 206 | - name: test without space before or after the comment start 207 | uses: mheap/test-action@sha-one#v1 208 | 209 | - name: test with random comment 210 | uses: mheap/test-action@sha-one # foobar 211 | 212 | - name: no final newline 213 | uses: mheap/test-action@sha-one # v1`; 214 | 215 | const actual = replaceActions(input, { 216 | ...action, 217 | pinnedVersion: "v1", 218 | currentVersion: "sha-one", 219 | newVersion: "sha-two", 220 | }); 221 | 222 | expect(actual).toContain("- uses: mheap/test-action@sha-two # v1"); 223 | expect(actual).toContain("uses: mheap/test-action@sha-two # v1"); 224 | expect(actual).toContain("uses : mheap/test-action@sha-two # v1"); 225 | expect(actual).toContain("uses: mheap/test-action@sha-two # v1"); 226 | expect(actual).toContain("uses: mheap/test-action@sha-two # v1"); 227 | expect(actual).toContain("uses: mheap/test-action@sha-two# v1"); 228 | expect(actual).toContain("uses: mheap/test-action@sha-two# v1"); 229 | expect(actual).toContain("uses: mheap/test-action@sha-two # v1"); 230 | expect(actual).toMatch(/uses: mheap\/test-action@sha-two # v1$/); 231 | }); 232 | 233 | test("maintains indentation when adding a pin", () => { 234 | let input = `name: PR 235 | on: 236 | - pull_request 237 | jobs: 238 | test-job: 239 | runs-on: ubuntu-latest 240 | steps: 241 | - name: test indentation #1 242 | uses: mheap/test-action@master 243 | `; 244 | 245 | let actual = replaceActions(input, { ...action }); 246 | expect(actual).toContain(" uses: mheap/test-action@sha-here # master"); 247 | 248 | input = `name: PR 249 | on: 250 | - pull_request 251 | jobs: 252 | test-job: 253 | runs-on: ubuntu-latest 254 | steps: 255 | - name: test indentation #2 256 | uses: mheap/test-action@master 257 | `; 258 | 259 | actual = replaceActions(input, { ...action }); 260 | expect(actual).toContain(" uses: mheap/test-action@sha-here # master"); 261 | }); 262 | 263 | test("maintains indentation when updating a pin", () => { 264 | let input = `name: PR 265 | on: 266 | - pull_request 267 | jobs: 268 | test-job: 269 | runs-on: ubuntu-latest 270 | steps: 271 | - name: test indentation #1 272 | uses: mheap/test-action@sha-one # v1 273 | `; 274 | 275 | let actual = replaceActions(input, { 276 | ...action, 277 | pinnedVersion: "v1", 278 | currentVersion: "sha-one", 279 | newVersion: "sha-two", 280 | }); 281 | expect(actual).toContain(" uses: mheap/test-action@sha-two # v1"); 282 | 283 | input = `name: PR 284 | on: 285 | - pull_request 286 | jobs: 287 | test-job: 288 | runs-on: ubuntu-latest 289 | steps: 290 | - name: test indentation #2 291 | uses: mheap/test-action@sha-one # v1 292 | `; 293 | 294 | actual = replaceActions(input, { 295 | ...action, 296 | pinnedVersion: "v1", 297 | currentVersion: "sha-one", 298 | newVersion: "sha-two", 299 | }); 300 | expect(actual).toContain(" uses: mheap/test-action@sha-two # v1"); 301 | }); 302 | 303 | test("handles RegExp meta-characters", () => { 304 | let input = `name: PR 305 | on: 306 | - pull_request 307 | jobs: 308 | test-job: 309 | runs-on: ubuntu-latest 310 | steps: 311 | - name: test indentation #1 312 | uses: mheap/test-action@sha-one # v1 313 | `; 314 | 315 | expect(() => { 316 | replaceActions(input, { 317 | ...action, 318 | owner: "a[b", 319 | repo: "c[d", 320 | path: "e[f", 321 | pinnedVersion: "v1", 322 | currentVersion: "sha[one", 323 | newVersion: "sha[two", 324 | }); 325 | }).not.toThrow(); 326 | }); 327 | 328 | test("supports single and double quotes uses values when adding a pin", () => { 329 | const input = `name: PR 330 | on: 331 | - pull_request 332 | jobs: 333 | test-job: 334 | runs-on: ubuntu-latest 335 | steps: 336 | - name: test with double quoted action 337 | uses: "mheap/test-action@master" 338 | 339 | - name: test with single quoted action 340 | uses: 'mheap/test-action@master'`; 341 | 342 | const actual = replaceActions(input, { ...action }); 343 | expect(actual).toContain('uses: "mheap/test-action@sha-here" # master'); 344 | expect(actual).toContain("uses: 'mheap/test-action@sha-here' # master"); 345 | }); 346 | 347 | test("supports single and double quotes uses values when updating a pin", () => { 348 | const input = `name: PR 349 | on: 350 | - pull_request 351 | jobs: 352 | test-job: 353 | runs-on: ubuntu-latest 354 | steps: 355 | - name: test where uses comes after the dash 356 | uses: "mheap/test-action@sha-one" # v1 357 | 358 | - name: test with extra leading spaces 359 | uses: 'mheap/test-action@sha-one' # v1`; 360 | 361 | const actual = replaceActions(input, { 362 | ...action, 363 | pinnedVersion: "v1", 364 | currentVersion: "sha-one", 365 | newVersion: "sha-two", 366 | }); 367 | 368 | expect(actual).toContain('uses: "mheap/test-action@sha-two" # v1'); 369 | expect(actual).toContain("uses: 'mheap/test-action@sha-two' # v1"); 370 | }); 371 | 372 | test("does not replace comments on the next line", () => { 373 | let input = `name: PR 374 | on: 375 | - pull_request 376 | jobs: 377 | test-job: 378 | runs-on: ubuntu-latest 379 | steps: 380 | - uses: mheap/test-action@master 381 | 382 | # This is a comment that should be untouched 383 | - uses: mheap/test-action@master 384 | `; 385 | 386 | let expected = `name: PR 387 | on: 388 | - pull_request 389 | jobs: 390 | test-job: 391 | runs-on: ubuntu-latest 392 | steps: 393 | - uses: mheap/test-action@sha-here # master 394 | 395 | # This is a comment that should be untouched 396 | - uses: mheap/test-action@sha-here # master 397 | `; 398 | 399 | let actual = replaceActions(input, { ...action }); 400 | expect(actual).toBe(expected); 401 | }); 402 | 403 | function convertToYaml(input) { 404 | return YAML.stringify(input); 405 | } 406 | --------------------------------------------------------------------------------