Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ repos:
language: system
pass_filenames: false

- repo: local
hooks:
- id: make-test
name: "Make Test"
entry: "make container-test"
language: system
pass_filenames: false

- repo: https://github.com/hukkin/mdformat
rev: 0.7.22
hooks:
Expand Down
29 changes: 15 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ By pinning actions to specific commit SHAs, you make your workflows more secure:

```yaml
steps:
- uses: actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675 #v4
- uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 #main
- uses: actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675 # v4
- uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # main
```

## Installation
Expand All @@ -50,7 +50,7 @@ gh ext install esacteksab/gh-actlock
## Usage

> [!NOTE]
> `gh-actlock` is designed to be run in the root directory of your Git repository. It expects a `.github/workflows/` directory containing your workflow files.
> `gh-actlock` is designed to be run in the root directory of your Git repository. It expects a `.github/` or `.github/workflows/` directory containing your action or workflow files.

### Commands

Expand All @@ -66,13 +66,13 @@ gh actlock

The extension will:

1. Find all workflow files in `.github/workflows/`
1. Analyze each file for GitHub Action references
1. Resolve non-SHA references (tags, branches) to their corresponding commit SHAs
1. Update each workflow file with pinned SHAs, preserving the original reference as a comment
1. Find all action files in `.github/` or workflow files in `.github/workflows/`.
1. Analyze each file and identify any action or shared workflow references.
1. Resolve non-SHA references (tags, branches) to their corresponding full commit SHAs.
1. Update each action or workflow file with the full commit SHA of the existing reference, preserving the original reference as an inline comment.

> [!IMPORTANT]
> Make sure you run the command from your repository's root directory where the `.github/workflows/` directory is located.
> Make sure you run the command from your repository's root directory where the `.github/` directory is located.

### Updating Pinned Actions and Shared Workflows

Expand All @@ -86,10 +86,10 @@ gh actlock --update

This will:

1. Find all workflow files in `.github/workflows/`
1. Identify actions and shared workflows that are already pinned or referenced by tags/versions
1. Find all action files in `.github/` or workflow files in `.github/workflows/`
1. Analyze each file and identify any action or shared workflow references
1. Check if newer versions are available
1. Update the SHAs to the latest[^1] version while preserving the original reference comment
1. Update the existing full commit SHA to the latest[^1] version's full commit SHA while preserving the original reference in an inline comment

For shared workflows, it converts references like `uses: owner/.github/.github/workflows/file.yml@tag` to use the corresponding SHA while keeping the original tag as a comment.

Expand Down Expand Up @@ -117,6 +117,7 @@ This will remove the application's cache directory located at:
- Only GitHub-hosted actions and shared workflows are pinned (`uses: owner/repo@ref` and `uses: owner/.github/.github/workflows/file.yml@ref`)
- Local actions and Docker actions are skipped
- Requires proper GitHub authentication for higher API rate limits
- Uses the default `yamllint` comment configuration (e.g. two spaces prior to a comment (#), one space after)

## Authentication

Expand Down Expand Up @@ -160,8 +161,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675 #v4
- uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d #v3
- uses: actions/checkout@a81bbbf8298c0fa03ea29cdc473d45769f953675 # v4
- uses: actions/setup-node@5e21ff4d9bc1a8cf6de233a3057d20ec6b3fb69d # v3
```

#### Pinning Shared Workflows Example
Expand Down Expand Up @@ -209,7 +210,7 @@ permissions:
contents: read
jobs:
goreleaser-check-reusable:
uses: esacteksab/.github/.github/workflows/tools.yml@7da1f735f5f18ecf049b40ab75503b1191756456 #0.5.3
uses: esacteksab/.github/.github/workflows/tools.yml@7da1f735f5f18ecf049b40ab75503b1191756456 # 0.5.3
```

## Keeping Pinned Actions Updated
Expand Down
90 changes: 65 additions & 25 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,28 +91,36 @@ var rootCmd = &cobra.Command{

// Construct the path to the workflows directory.
workflowsDir := filepath.Join(ghDir, wfDir)
// Read the directory entries.
// Read the workflows directory entries.
workflows, err := os.ReadDir(workflowsDir)
if err != nil {
// If the directory doesn't exist, provide a specific error message.
if os.IsNotExist(err) {
log.Fatalf("Workflows directory not found: %s", workflowsDir)
log.Printf("Workflows directory not found: %s", workflowsDir)
}
// For any other error reading the directory, log a fatal error.
log.Fatalf("Error reading workflows directory '%s': %v", workflowsDir, err)
// For any other error reading the directory, log a error.
log.Printf("Error reading workflows directory '%s': %v", workflowsDir, err)
}

// If no files are found in the directory, print a message and exit.
// If no files are found in the directory, print a message.
if len(workflows) == 0 {
log.Printf("No workflow files found in %s", workflowsDir)
return
}
actions, err := os.ReadDir(ghDir)
if err != nil {
// if the directory doesn't exist, provide a specific error message.
if os.IsNotExist(err) {
log.Fatalf("GitHub directory not found: %s", ghDir)
}
// for any other error reading the directory, log a fatal error.
log.Fatalf("Error reading GitHub directory '%s': %v", ghDir, err)
}

log.Printf("Found %d potential workflow files in %s", len(workflows), workflowsDir)
totalUpdates := 0

// Iterate through each entry found in the workflows directory.
for _, wf := range workflows {
for _, wf := range workflows { //nolint:dupl
// Skip directories and files starting with '.' (like .gitignore).
if wf.IsDir() || strings.HasPrefix(wf.Name(), ".") {
continue
Expand Down Expand Up @@ -141,6 +149,37 @@ var rootCmd = &cobra.Command{
log.Printf("ℹ️ No actions needed updating in %s", filePath)
}
}
// Iterate through each entry found in the GitHub directory.
for _, action := range actions { //nolint:dupl
// Skip directories and files starting with '.' (like .gitignore).
if action.IsDir() || strings.HasPrefix(action.Name(), ".") {
continue
}
// Only process files with .yml or .yaml extensions (case-insensitive comparison isn't strictly needed here based on typical filenames).
if !strings.HasSuffix(action.Name(), ".yml") &&
!strings.HasSuffix(action.Name(), ".yaml") {
log.Printf("Skipping non-YAML file: %s", action.Name())
continue
}

// Construct the full path to the action file.
filePath := filepath.Join(ghDir, action.Name())
log.Printf("Processing action: %s", filePath)

// Call the function to update SHAs within this specific workflow file.
updated, err := UpdateWorkflowActionSHAs(ctx, client, filePath)
if err != nil {
// Log errors related to processing a single file but continue to the next.
log.Printf("❌ Failed to process %s: %v", filePath, err)
} else if updated > 0 {
// Log success if updates were made.
log.Printf("✅ Updated %d action(s) in %s", updated, filePath)
totalUpdates += updated
} else {
// Log if no updates were needed for the file.
log.Printf("ℹ️ No actions needed updating in %s", filePath)
}
}
// Final summary of total updates made across all files.
log.Printf("Finished processing. Total actions updated across all files: %d", totalUpdates)
},
Expand Down Expand Up @@ -372,7 +411,7 @@ func handleWorkflowReference(
}

// Create the new workflow reference string with SHA + comment
newUsesValue := fmt.Sprintf("%s@%s #%s", fullPathForUses, commitSHA, latestRef)
newUsesValue := fmt.Sprintf("%s@%s # %s", fullPathForUses, commitSHA, latestRef)

// Log the update details
log.Printf(
Expand Down Expand Up @@ -429,7 +468,7 @@ func handleWorkflowReference(
}

// Create the new workflow reference string with SHA + comment
newUsesValue := fmt.Sprintf("%s@%s #%s", fullPathForUses, commitSHA, originalRefForComment)
newUsesValue := fmt.Sprintf("%s@%s # %s", fullPathForUses, commitSHA, originalRefForComment)
log.Printf(" Pinned workflow %s@%s to SHA %s", fullPathForUses, originalRefForComment, commitSHA[:8])

// Store the update in the map and increment counter
Expand Down Expand Up @@ -514,7 +553,7 @@ func handleActionReference(

// Create the new action reference string with SHA + comment
newUsesValue := fmt.Sprintf(
"%s@%s #%s", // Format: owner/repo/subpath@sha #ref
"%s@%s # %s", // Format: owner/repo/subpath@sha # ref
fullPathForUses,
commitSHA, // Use the full SHA for pinning
latestRef, // Include latest reference as a comment
Expand Down Expand Up @@ -566,7 +605,7 @@ func handleActionReference(
}

// Create the new action reference string with SHA + comment
newUsesValue := fmt.Sprintf("%s@%s #%s", fullPathForUses, commitSHA, ref)
newUsesValue := fmt.Sprintf("%s@%s # %s", fullPathForUses, commitSHA, ref)
log.Printf(" Pinned action %s@%s to SHA %s", fullPathForUses, ref, commitSHA[:8])

// Store the update in the map and increment counter
Expand Down Expand Up @@ -676,22 +715,23 @@ func applyUpdatesToLines(originalContent string, updates map[int]string) (string
// An update exists for this line.
// Trim whitespace from the beginning of the line to check if it starts with 'uses:'.
trimmedLine := strings.TrimSpace(line)
// Verify that the line actually starts with 'uses:' (case-sensitive as per YAML spec).
if strings.HasPrefix(trimmedLine, "uses:") ||
strings.HasPrefix(trimmedLine, "- uses:") {
// Identify the leading indentation (spaces and tabs) of the original line.
indentation := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
// Construct the new line, preserving the dash if it exists
newLine := ""
if strings.HasPrefix(trimmedLine, "- uses:") {
// If it has the dash prefix, maintain it in the updated line
newLine = indentation + "- uses: " + newUsesValue
// Verify that the line actually contains 'uses:' somewhere.
// Handle cases like "- uses:", nested "- - uses:", or "uses:" with arbitrary indentation.
if strings.Contains(trimmedLine, "uses:") {
// Find the index of "uses:" in the original line to preserve exact leading whitespace and any dashes.
idx := strings.Index(line, "uses:")
if idx == -1 {
// Fallback: if not found in original line (shouldn't happen since trimmedLine contains it), append original.
log.Printf(
"Warning: couldn't locate 'uses:' position on line %d. Appending original.",
lineNumber,
)
output.WriteString(line)
} else {
// Regular "uses:" line without dash
newLine = indentation + "uses: " + newUsesValue
// Replace from the "uses:" token onward with the new value while preserving prefix.
newLine := line[:idx] + "uses: " + newUsesValue
output.WriteString(newLine)
}
// Write the new line to the output buffer.
output.WriteString(newLine)
} else {
// If an update was mapped to this line number, but the line content doesn't look like
// a 'uses:' entry, log a warning. This indicates a potential issue with the line
Expand Down
4 changes: 2 additions & 2 deletions testdata/script/pin_github_actions.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout v4 (branch/tag)
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 #v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Setup Go v5 (branch/tag)
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 #v5
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5
- name: Action with specific SHA (should not change)
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29
- name: Non-existent ref
Expand Down
2 changes: 1 addition & 1 deletion testdata/script/pin_github_actions_42.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Gradle v4 (branch/tag)
uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 #v4.4.2
uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3
53 changes: 53 additions & 0 deletions testdata/script/pin_github_workflow.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
exec actlock

stdout '🔧 Authenticated GitHub API access in effect.'

# Compare the modified file (in $WORK) with the expected output (also in $WORK)
cmp .github/workflows/test.yml expected.yml

-- .github/workflows/test.yml --
name: Tools - Check

on:
pull_request:
branches:
- "main"
paths:
- "**.go"
- "**.mod"
- "**.sum"
- ".goreleaser.yaml"

concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: true

permissions:
contents: read

jobs:
goreleaser-check-reusable:
uses: esacteksab/.github/.github/workflows/[email protected]
-- expected.yml --
name: Tools - Check

on:
pull_request:
branches:
- "main"
paths:
- "**.go"
- "**.mod"
- "**.sum"
- ".goreleaser.yaml"

concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: true

permissions:
contents: read

jobs:
goreleaser-check-reusable:
uses: esacteksab/.github/.github/workflows/tools.yml@7da1f735f5f18ecf049b40ab75503b1191756456 # 0.5.3
52 changes: 52 additions & 0 deletions testdata/script/update_action.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
exec actlock -u

# Check stderr for expected logs (using regex for robustness)
stdout '🔧 Authenticated GitHub API access in effect.'

# Compare the modified file (in $WORK) with the expected output (also in $WORK)
cmp .github/action.yml expected.yml


# Setup initial workflow file relative to the temp $WORK dir
-- .github/action.yml --
name: pre-commit
description: run pre-commit
inputs:
extra_args:
description: options to pass to pre-commit run
required: false
default: '--all-files'
runs:
using: composite
steps:
- run: python -m pip install pre-commit
shell: bash
- run: python -m pip freeze --local
shell: bash
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- run: pre-commit run --show-diff-on-failure --color=always ${{ inputs.extra_args }}
shell: bash
-- expected.yml --
name: pre-commit
description: run pre-commit
inputs:
extra_args:
description: options to pass to pre-commit run
required: false
default: '--all-files'
runs:
using: composite
steps:
- run: python -m pip install pre-commit
shell: bash
- run: python -m pip freeze --local
shell: bash
- uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.cache/pre-commit
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- run: pre-commit run --show-diff-on-failure --color=always ${{ inputs.extra_args }}
shell: bash
8 changes: 4 additions & 4 deletions testdata/script/update_github_actions.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout v4 (branch/tag)
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Go v5 (branch/tag)
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 #v5.5.0
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
- name: Action with specific SHA (should not change)
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Non-existent ref
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
Loading
Loading