├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── images └── example.png └── main.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version to release' 8 | required: true 9 | default: 'v1.0.0' 10 | 11 | permissions: 12 | contents: write 13 | 14 | jobs: 15 | build: 16 | if: github.actor == 'santrancisco' 17 | name: Build Binaries 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v4 26 | with: 27 | go-version: 1.16 28 | 29 | - name: Build binaries 30 | run: make build 31 | 32 | - name: Upload binaries 33 | uses: actions/upload-artifact@v4 34 | with: 35 | name: binaries 36 | path: bin/ 37 | 38 | release: 39 | if: github.actor == 'santrancisco' 40 | name: Create Release 41 | needs: build 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - name: Download binaries 46 | uses: actions/download-artifact@v4 47 | with: 48 | name: binaries 49 | path: bin/ 50 | 51 | - name: Create GitHub Release 52 | uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 #v1.16.0 53 | with: 54 | artifacts: "bin/*" 55 | token: ${{ secrets.GITHUB_TOKEN }} 56 | tag: ${{ github.event.inputs.version }} 57 | name: Release ${{ github.event.inputs.version }} 58 | body: | 59 | This release contains the following binaries: 60 | - macOS (arm64) 61 | - Windows (amd64) 62 | - Linux (amd64) 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 San Tran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile to compile main.go for aarch64 (mac), windows, and linux 2 | 3 | APP_NAME := pmw 4 | SRC_FILE := main.go 5 | BIN_DIR := bin 6 | 7 | build: mac windows linux 8 | 9 | mac: 10 | mkdir -p $(BIN_DIR) 11 | GOOS=darwin GOARCH=arm64 go build -o $(BIN_DIR)/$(APP_NAME)-mac $(SRC_FILE) 12 | 13 | windows: 14 | mkdir -p $(BIN_DIR) 15 | GOOS=windows GOARCH=amd64 go build -o $(BIN_DIR)/$(APP_NAME)-windows.exe $(SRC_FILE) 16 | 17 | linux: 18 | mkdir -p $(BIN_DIR) 19 | GOOS=linux GOARCH=amd64 go build -o $(BIN_DIR)/$(APP_NAME)-linux $(SRC_FILE) 20 | 21 | clean: 22 | rm -rf $(BIN_DIR) 23 | 24 | .PHONY: build mac windows linux clean -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Pin My Workflows (PMW) 2 | 3 | Pin my workflows(pmw) is a command‑line tool designed to automate the process of pinning GitHub Actions workflow version tags to their corresponding commit SHA hashes. It scans your repository’s workflow files, identifies workflow usages that are not from an allowed organization and that aren’t pinned to a full commit SHA, and then uses the GitHub API to resolve the version tag. 4 | 5 | ### Features 6 | 7 | - Scans .github/workflows for workflow uses: lines that use version tags and replaces them with their corresponding commit SHA. 8 | 9 | - Allow user to add a list of organisations that workflow can be tracked via version control (This is useful when you want to stay up-to-date with minor versions of workflows from reputable organisations) 10 | 11 | - Prompt user to accept change, add workflow to allowed organisations (skipping future prompts). 12 | 13 | - Store the list of pinned version to skip future prompts of the same action with same version tag. 14 | 15 | - Nested Tag Resolution: I found some workflow had tag reference another tag with commit, this app should be able to iterates and reach a commit SHA. 16 | 17 | Example: 18 | 19 | ![image](/images/example.png) -------------------------------------------------------------------------------- /images/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/santrancisco/pmw/087cd44de98c6ed65d4bc709dd0a6b85c827a31b/images/example.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "os" 10 | "flag" 11 | "path/filepath" 12 | "regexp" 13 | "strings" 14 | "time" 15 | "os/signal" 16 | "syscall" 17 | "sort" 18 | ) 19 | 20 | var ColorReset = "\033[0m" 21 | var ColorRed = "\033[31m" 22 | var ColorGreen = "\033[32m" 23 | var ColorYellow = "\033[33m" 24 | var ColorBlue = "\033[34m" 25 | var ColorMagenta = "\033[35m" 26 | var ColorCyan = "\033[36m" 27 | var ColorGray = "\033[37m" 28 | var ColorWhite = "\033[97m" 29 | 30 | // AllowedOrgs: Keeping the list of organisation where we accept version tagging for their workflow 31 | // AcceptedMapping: List of previously accepted versions & their commit hashes. 32 | type Config struct { 33 | AllowedOrgs []string `json:"allowedOrgs"` 34 | AcceptedMapping map[string]string `json:"acceptedMapping"` 35 | } 36 | 37 | var configFile = ".github/pmw-config.json" 38 | var verbose = false 39 | var acceptall = false 40 | var config Config 41 | 42 | func loadConfig() error { 43 | data, err := ioutil.ReadFile(configFile) 44 | if err != nil { 45 | if os.IsNotExist(err) { 46 | fmt.Println("No configuration found. Please enter a comma-separated list of allowed organizations:") 47 | reader := bufio.NewReader(os.Stdin) 48 | input, _ := reader.ReadString('\n') 49 | input = strings.TrimSpace(input) 50 | var orgs []string 51 | // we don't want empty array when user just hit enter. 52 | if strings.TrimSpace(input) != "" { 53 | orgs = strings.Split(input, ",") 54 | for i, org := range orgs { 55 | orgs[i] = strings.TrimSpace(org) 56 | } 57 | } 58 | config = Config{ 59 | AllowedOrgs: orgs, 60 | AcceptedMapping: make(map[string]string), 61 | } 62 | // Ensure .github folder exists 63 | os.MkdirAll(".github", os.ModePerm) 64 | return saveConfig() 65 | } 66 | return err 67 | } 68 | return json.Unmarshal(data, &config) 69 | } 70 | 71 | // saveConfig saves the current configuration into the config file. 72 | func saveConfig() error { 73 | data, err := json.MarshalIndent(config, "", " ") 74 | if err != nil { 75 | return err 76 | } 77 | return ioutil.WriteFile(configFile, data, 0644) 78 | } 79 | 80 | 81 | type GitRefResponse struct { 82 | Object struct { 83 | Type string `json:"type"` 84 | Sha string `json:"sha"` 85 | } `json:"object"` 86 | } 87 | 88 | type GitTagResponse struct { 89 | Object struct { 90 | Type string `json:"type"` 91 | Sha string `json:"sha"` 92 | } `json:"object"` 93 | } 94 | 95 | // Resolving tag reference via github API 96 | func resolveTag(owner, repo, sha string) (string, error) { 97 | url := fmt.Sprintf("https://api.github.com/repos/%s/%s/git/tags/%s", owner, repo, sha) 98 | resp, err := http.Get(url) 99 | if err != nil { 100 | return "", err 101 | } 102 | defer resp.Body.Close() 103 | if resp.StatusCode != 200 { 104 | return "", fmt.Errorf("GitHub API returned status: %d", resp.StatusCode) 105 | } 106 | var tagResponse GitTagResponse 107 | if err := json.NewDecoder(resp.Body).Decode(&tagResponse); err != nil { 108 | return "", err 109 | } 110 | return tagResponse.Object.Sha, nil 111 | } 112 | 113 | 114 | func compareVersions(v1, v2 string) int { 115 | parts1 := strings.Split(v1, ".") 116 | parts2 := strings.Split(v2, ".") 117 | maxLen := len(parts1) 118 | if len(parts2) > maxLen { 119 | maxLen = len(parts2) 120 | } 121 | for i := 0; i < maxLen; i++ { 122 | var num1, num2 int 123 | if i < len(parts1) { 124 | fmt.Sscanf(parts1[i], "%d", &num1) 125 | } 126 | if i < len(parts2) { 127 | fmt.Sscanf(parts2[i], "%d", &num2) 128 | } 129 | if num1 > num2 { 130 | return 1 131 | } else if num1 < num2 { 132 | return -1 133 | } 134 | } 135 | return 0 136 | } 137 | 138 | // Turn out when user put in v2, github workflow will find the latest version that has prefix "v2".. .so it could be v2.x.x, v2.x 139 | // As such we will need to go through all tags and compare the versions. 140 | func findLatestVersionTag(owner, repo, prefix string) (string, error) { 141 | url := fmt.Sprintf("https://api.github.com/repos/%s/%s/tags", owner, repo) 142 | resp, err := http.Get(url) 143 | if err != nil { 144 | return "", err 145 | } 146 | defer resp.Body.Close() 147 | if resp.StatusCode != 200 { 148 | return "", fmt.Errorf("GitHub API returned status: %d", resp.StatusCode) 149 | } 150 | var tags []struct { 151 | Name string `json:"name"` 152 | Commit struct { 153 | Sha string `json:"sha"` 154 | } `json:"commit"` 155 | } 156 | if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil { 157 | return "", err 158 | } 159 | var filtered []string 160 | for _, t := range tags { 161 | if strings.HasPrefix(t.Name, prefix) { 162 | filtered = append(filtered, t.Name) 163 | } 164 | } 165 | if len(filtered) == 0 { 166 | return "", fmt.Errorf("no tags found with prefix %s", prefix) 167 | } 168 | // Sort filtered tags in descending order (latest first) using semantic version comparison. 169 | sort.Slice(filtered, func(i, j int) bool { 170 | v1 := strings.TrimPrefix(filtered[i], "v") 171 | v2 := strings.TrimPrefix(filtered[j], "v") 172 | return compareVersions(v1, v2) > 0 173 | }) 174 | latestTag := filtered[0] 175 | for _, t := range tags { 176 | if t.Name == latestTag { 177 | return t.Commit.Sha, nil 178 | } 179 | } 180 | return "", fmt.Errorf("could not resolve latest tag for prefix %s", prefix) 181 | } 182 | 183 | // Getting commit sha for version tags, iterate through nested tag if neccessary 184 | // for workflow pin to master, we just get the hash of master branch 185 | func getCommitSha(owner, repo, tag string) (string, error) { 186 | // Some workflows are in subfolder, we just need the repo here. 187 | repo=strings.Split(repo,"/")[0] 188 | var orginUrl string 189 | if tag == "master" { 190 | orginUrl = fmt.Sprintf("https://api.github.com/repos/%s/%s/git/ref/heads/master", owner, repo) 191 | } else if tag == "main" { 192 | orginUrl = fmt.Sprintf("https://api.github.com/repos/%s/%s/git/ref/heads/main", owner, repo) 193 | } else { 194 | return findLatestVersionTag(owner, repo, tag) 195 | } 196 | resp, err := http.Get(orginUrl) 197 | if err != nil { 198 | return "", err 199 | } 200 | defer resp.Body.Close() 201 | if resp.StatusCode != 200 { 202 | return "", fmt.Errorf("GitHub API returned status: %d", resp.StatusCode) 203 | } 204 | var tagResponse GitTagResponse 205 | if err := json.NewDecoder(resp.Body).Decode(&tagResponse); err != nil { 206 | return "", err 207 | } 208 | return tagResponse.Object.Sha, nil 209 | } 210 | 211 | // isAllowedOrg returns true if the given owner is in the allowed organizations list. 212 | func isAllowedOrg(owner string) bool { 213 | for _, org := range config.AllowedOrgs { 214 | if strings.EqualFold(owner, org) { 215 | return true 216 | } 217 | } 218 | return false 219 | } 220 | 221 | // We are searching for workflow usages: 222 | // uses: owner/repo@tag 223 | var usageRegex = regexp.MustCompile(`uses:\s*([^/]+)/([^@]+)@(\S+)`) 224 | 225 | // processFile go through each line in workflow 226 | func processFile(filePath string) error { 227 | if verbose == true { 228 | fmt.Printf("[+] Processing %s\n", filePath) 229 | } 230 | inputBytes, err := ioutil.ReadFile(filePath) 231 | if err != nil { 232 | return err 233 | } 234 | lines := strings.Split(string(inputBytes), "\n") 235 | changed := false 236 | 237 | for i, line := range lines { 238 | matches := usageRegex.FindStringSubmatch(line) 239 | if matches == nil { 240 | continue 241 | } 242 | owner, repo, tag := matches[1], matches[2], matches[3] 243 | 244 | // Skip if owner is allowed or tag is already a commit hash. 245 | if isAllowedOrg(owner) || regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(tag) { 246 | continue 247 | } 248 | 249 | key := fmt.Sprintf("%s/%s@%s", owner, repo, tag) 250 | var commitSha string 251 | var ok bool 252 | 253 | if commitSha, ok = config.AcceptedMapping[key]; ok { 254 | fmt.Println("-----------------------") 255 | fmt.Printf(ColorGreen+"Previously accepted for %s: using commit %s\n"+ColorReset, key, commitSha) 256 | } else { 257 | fmt.Println("-----------------------") 258 | commitSha, err = getCommitSha(owner, repo, tag) 259 | if err != nil { 260 | fmt.Printf("Error retrieving commit SHA for %s: %v\n", key, err) 261 | continue 262 | } 263 | var versionTag string 264 | var checkUrl string 265 | if tag == "master" { 266 | versionTag = fmt.Sprintf("#master-%s", time.Now().Format("2006-01-02")) 267 | checkUrl = fmt.Sprintf("https://github.com/%s/%s/commits/master/", owner, repo) 268 | } else { 269 | versionTag = fmt.Sprintf("#%s", tag) 270 | checkUrl = fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", owner, repo, tag) 271 | } 272 | newUsage := fmt.Sprintf("uses: %s/%s@%s %s", owner, repo, commitSha, versionTag) 273 | fmt.Printf("[.]In File: %s\n"+ColorRed+"[-]Old: %s (Check URL: %s)\n"+ColorReset+ColorBlue+"[+]New: %s\n"+ColorReset+"Choose option: (y)es, (n)o, (a)dd to allowedOrgs, (q)uit: ", filePath, strings.TrimSpace(line), checkUrl, newUsage) 274 | answer := "y" 275 | if !acceptall { 276 | reader := bufio.NewReader(os.Stdin) 277 | answer, _ := reader.ReadString('\n') 278 | answer = strings.TrimSpace(strings.ToLower(answer)) 279 | } 280 | switch answer { 281 | case "y": 282 | config.AcceptedMapping[key] = commitSha 283 | commitSha = config.AcceptedMapping[key] 284 | lines[i] = newUsage 285 | changed = true 286 | case "n": 287 | continue 288 | case "a": 289 | config.AllowedOrgs = append(config.AllowedOrgs, owner) 290 | fmt.Printf("Added %s to allowed organizations.\n", owner) 291 | continue 292 | case "q": 293 | fmt.Println("Quitting processing...") 294 | if err := saveConfig(); err != nil { 295 | fmt.Printf("Error saving config: %v\n", err) 296 | } 297 | os.Exit(0) 298 | default: 299 | fmt.Println("Invalid option, skipping.") 300 | continue 301 | } 302 | } 303 | 304 | // Even if the mapping was already accepted previously, ensure the version tag comment is updated on the day we run it again 305 | var versionTag string 306 | if tag == "master" { 307 | versionTag = fmt.Sprintf("#master-%s", time.Now().Format("2006-01-02")) 308 | } else { 309 | versionTag = fmt.Sprintf("#%s", tag) 310 | } 311 | leadingWhitespace := "" 312 | for _, r := range line { 313 | if r == ' ' || r == '\t' { 314 | leadingWhitespace += string(r) 315 | } else { 316 | break 317 | } 318 | } 319 | lines[i] = fmt.Sprintf("%suses: %s/%s@%s %s",leadingWhitespace, owner, repo, commitSha, versionTag) 320 | changed = true 321 | } 322 | if changed { 323 | newContent := strings.Join(lines, "\n") 324 | err = ioutil.WriteFile(filePath, []byte(newContent), 0644) 325 | if err != nil { 326 | return err 327 | } 328 | fmt.Printf(ColorGreen+"Updated file: %s\n"+ColorReset, filePath) 329 | } 330 | return nil 331 | } 332 | 333 | func main() { 334 | // Allow using config file but by default, we save our config right into .github folder. 335 | configPath := flag.String("c", ".github/pmw-config.json", "Path to configuration file") 336 | verboseMode := flag.Bool("v", false, "Verbose mode") 337 | yesMode := flag.Bool("y", false, "Yes mode - accept all the changes automatically, you will need to review the configuration file later.") 338 | flag.Parse() 339 | configFile = *configPath 340 | verbose = *verboseMode 341 | acceptall = *yesMode 342 | 343 | if err := loadConfig(); err != nil { 344 | fmt.Println("Error loading config:", err) 345 | return 346 | } 347 | 348 | // Catching Ctrl+C and SIGTERM and save config halfway 349 | sigChan := make(chan os.Signal, 1) 350 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 351 | go func() { 352 | sig := <-sigChan 353 | fmt.Printf("Received signal: %v, saving config and exiting...\n", sig) 354 | if err := saveConfig(); err != nil { 355 | fmt.Printf("Error saving config: %v\n", err) 356 | } 357 | os.Exit(0) 358 | }() 359 | 360 | err := filepath.Walk(".github/workflows", func(path string, info os.FileInfo, err error) error { 361 | if err != nil { 362 | return err 363 | } 364 | if info.IsDir() { 365 | return nil 366 | } 367 | if strings.HasSuffix(info.Name(), ".yml") || strings.HasSuffix(info.Name(), ".yaml") { 368 | if err := processFile(path); err != nil { 369 | fmt.Printf("Error processing file %s: %v\n", path, err) 370 | } 371 | } 372 | return nil 373 | }) 374 | if err != nil { 375 | fmt.Println("Error walking directory:", err) 376 | } 377 | if err := saveConfig(); err != nil { 378 | fmt.Println("Error saving config:", err) 379 | } 380 | fmt.Printf("Configuration is saved to %s\n",configFile) 381 | if acceptall { 382 | fmt.Println(ColorCyan+"You used (Y)es mode, please review the config file and make sure all workflows are correctly pinned."+ColorReset) 383 | } 384 | 385 | } --------------------------------------------------------------------------------